From 8f703a29ac4dd46d8f5eacee09b9fbb73ede9031 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:20:42 -0500 Subject: [PATCH 01/30] [deps] DbOps: Update Microsoft.Azure.Cosmos to 3.46.0 (#5066) 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 5970629caa..fd4d8cc7e1 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -36,7 +36,7 @@ - + From 1b75e35c319f588d9e8a5fe7fd9ccd6591813cf1 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 26 Nov 2024 16:37:12 -0600 Subject: [PATCH 02/30] [PM-10319] - Revoke Non Complaint Users for 2FA and Single Org Policy Enablement (#5037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revoking users when enabling single org and 2fa policies. - Updated emails sent when users are revoked via 2FA or Single Organization policy enablement Co-authored-by: Matt Bishop Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> --- .../AdminConsole/Enums/EventSystemUser.cs | 1 + .../AdminConsole/Models/Data/IActingUser.cs | 10 + .../AdminConsole/Models/Data/StandardUser.cs | 16 ++ .../AdminConsole/Models/Data/SystemUser.cs | 16 ++ .../VerifyOrganizationDomainCommand.cs | 93 +++++---- ...vokeNonCompliantOrganizationUserCommand.cs | 9 + .../Requests/RevokeOrganizationUserRequest.cs | 13 ++ ...vokeNonCompliantOrganizationUserCommand.cs | 112 +++++++++++ .../Policies/Models/PolicyUpdate.cs | 2 + .../SingleOrgPolicyValidator.cs | 70 ++++++- .../TwoFactorAuthenticationPolicyValidator.cs | 69 ++++++- .../IOrganizationUserRepository.cs | 2 + .../AdminConsole/Services/IPolicyService.cs | 2 +- .../Services/Implementations/PolicyService.cs | 15 +- ...tionUserRevokedForSingleOrgPolicy.html.hbs | 14 ++ ...tionUserRevokedForSingleOrgPolicy.text.hbs | 5 + ...tionUserRevokedForTwoFactorPolicy.html.hbs | 15 ++ ...tionUserRevokedForTwoFactorPolicy.text.hbs | 7 + src/Core/Models/Commands/CommandResult.cs | 12 ++ ...nUserRevokedForPolicySingleOrgViewModel.cs | 6 + ...nUserRevokedForPolicyTwoFactorViewModel.cs | 6 + ...OrganizationServiceCollectionExtensions.cs | 1 + src/Core/Services/IMailService.cs | 2 + .../Implementations/HandlebarsMailService.cs | 31 ++- .../NoopImplementations/NoopMailService.cs | 6 + .../OrganizationUserRepository.cs | 10 + .../OrganizationUserRepository.cs | 12 ++ ...OrganizationUser_SetStatusForUsersById.sql | 29 +++ .../AutoFixture/PolicyUpdateFixtures.cs | 4 +- .../VerifyOrganizationDomainCommandTests.cs | 38 +++- ...onCompliantOrganizationUserCommandTests.cs | 185 ++++++++++++++++++ .../SingleOrgPolicyValidatorTests.cs | 151 ++++++++++++++ ...actorAuthenticationPolicyValidatorTests.cs | 150 +++++++++++++- .../Policies/SavePolicyCommandTests.cs | 4 +- .../Services/EventServiceTests.cs | 1 - .../2024-11-26-00_OrgUserSetStatusBulk.sql | 28 +++ 36 files changed, 1074 insertions(+), 73 deletions(-) create mode 100644 src/Core/AdminConsole/Models/Data/IActingUser.cs create mode 100644 src/Core/AdminConsole/Models/Data/StandardUser.cs create mode 100644 src/Core/AdminConsole/Models/Data/SystemUser.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeNonCompliantOrganizationUserCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/RevokeOrganizationUserRequest.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs create mode 100644 src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForTwoFactorPolicy.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForTwoFactorPolicy.text.hbs create mode 100644 src/Core/Models/Commands/CommandResult.cs create mode 100644 src/Core/Models/Mail/OrganizationUserRevokedForPolicySingleOrgViewModel.cs create mode 100644 src/Core/Models/Mail/OrganizationUserRevokedForPolicyTwoFactorViewModel.cs create mode 100644 src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersById.sql create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommandTests.cs create mode 100644 util/Migrator/DbScripts/2024-11-26-00_OrgUserSetStatusBulk.sql diff --git a/src/Core/AdminConsole/Enums/EventSystemUser.cs b/src/Core/AdminConsole/Enums/EventSystemUser.cs index df9be9a350..c3e13705dd 100644 --- a/src/Core/AdminConsole/Enums/EventSystemUser.cs +++ b/src/Core/AdminConsole/Enums/EventSystemUser.cs @@ -2,6 +2,7 @@ public enum EventSystemUser : byte { + Unknown = 0, SCIM = 1, DomainVerification = 2, PublicApi = 3, diff --git a/src/Core/AdminConsole/Models/Data/IActingUser.cs b/src/Core/AdminConsole/Models/Data/IActingUser.cs new file mode 100644 index 0000000000..f97235f34c --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/IActingUser.cs @@ -0,0 +1,10 @@ +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Models.Data; + +public interface IActingUser +{ + Guid? UserId { get; } + bool IsOrganizationOwnerOrProvider { get; } + EventSystemUser? SystemUserType { get; } +} diff --git a/src/Core/AdminConsole/Models/Data/StandardUser.cs b/src/Core/AdminConsole/Models/Data/StandardUser.cs new file mode 100644 index 0000000000..f21a41db7c --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/StandardUser.cs @@ -0,0 +1,16 @@ +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Models.Data; + +public class StandardUser : IActingUser +{ + public StandardUser(Guid userId, bool isOrganizationOwner) + { + UserId = userId; + IsOrganizationOwnerOrProvider = isOrganizationOwner; + } + + public Guid? UserId { get; } + public bool IsOrganizationOwnerOrProvider { get; } + public EventSystemUser? SystemUserType => throw new Exception($"{nameof(StandardUser)} does not have a {nameof(SystemUserType)}"); +} diff --git a/src/Core/AdminConsole/Models/Data/SystemUser.cs b/src/Core/AdminConsole/Models/Data/SystemUser.cs new file mode 100644 index 0000000000..c4859f928f --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/SystemUser.cs @@ -0,0 +1,16 @@ +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Models.Data; + +public class SystemUser : IActingUser +{ + public SystemUser(EventSystemUser systemUser) + { + SystemUserType = systemUser; + } + + public Guid? UserId => throw new Exception($"{nameof(SystemUserType)} does not have a {nameof(UserId)}."); + + public bool IsOrganizationOwnerOrProvider => false; + public EventSystemUser? SystemUserType { get; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index 870fa72aa7..dd6add669f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -1,7 +1,9 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.Services; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -12,124 +14,121 @@ using Microsoft.Extensions.Logging; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; -public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand +public class VerifyOrganizationDomainCommand( + IOrganizationDomainRepository organizationDomainRepository, + IDnsResolverService dnsResolverService, + IEventService eventService, + IGlobalSettings globalSettings, + IPolicyService policyService, + IFeatureService featureService, + ICurrentContext currentContext, + ILogger logger) + : IVerifyOrganizationDomainCommand { - private readonly IOrganizationDomainRepository _organizationDomainRepository; - private readonly IDnsResolverService _dnsResolverService; - private readonly IEventService _eventService; - private readonly IGlobalSettings _globalSettings; - private readonly IPolicyService _policyService; - private readonly IFeatureService _featureService; - private readonly ILogger _logger; - - public VerifyOrganizationDomainCommand( - IOrganizationDomainRepository organizationDomainRepository, - IDnsResolverService dnsResolverService, - IEventService eventService, - IGlobalSettings globalSettings, - IPolicyService policyService, - IFeatureService featureService, - ILogger logger) - { - _organizationDomainRepository = organizationDomainRepository; - _dnsResolverService = dnsResolverService; - _eventService = eventService; - _globalSettings = globalSettings; - _policyService = policyService; - _featureService = featureService; - _logger = logger; - } public async Task UserVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain) { - var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain); + if (currentContext.UserId is null) + { + throw new InvalidOperationException( + $"{nameof(UserVerifyOrganizationDomainAsync)} can only be called by a user. " + + $"Please call {nameof(SystemVerifyOrganizationDomainAsync)} for system users."); + } - await _eventService.LogOrganizationDomainEventAsync(domainVerificationResult, + var actingUser = new StandardUser(currentContext.UserId.Value, await currentContext.OrganizationOwner(organizationDomain.OrganizationId)); + + var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain, actingUser); + + await eventService.LogOrganizationDomainEventAsync(domainVerificationResult, domainVerificationResult.VerifiedDate != null ? EventType.OrganizationDomain_Verified : EventType.OrganizationDomain_NotVerified); - await _organizationDomainRepository.ReplaceAsync(domainVerificationResult); + await organizationDomainRepository.ReplaceAsync(domainVerificationResult); return domainVerificationResult; } public async Task SystemVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain) { + var actingUser = new SystemUser(EventSystemUser.DomainVerification); + organizationDomain.SetJobRunCount(); - var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain); + var domainVerificationResult = await VerifyOrganizationDomainAsync(organizationDomain, actingUser); if (domainVerificationResult.VerifiedDate is not null) { - _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully validated domain"); + logger.LogInformation(Constants.BypassFiltersEventId, "Successfully validated domain"); - await _eventService.LogOrganizationDomainEventAsync(domainVerificationResult, + await eventService.LogOrganizationDomainEventAsync(domainVerificationResult, EventType.OrganizationDomain_Verified, EventSystemUser.DomainVerification); } else { - domainVerificationResult.SetNextRunDate(_globalSettings.DomainVerification.VerificationInterval); + domainVerificationResult.SetNextRunDate(globalSettings.DomainVerification.VerificationInterval); - await _eventService.LogOrganizationDomainEventAsync(domainVerificationResult, + await eventService.LogOrganizationDomainEventAsync(domainVerificationResult, EventType.OrganizationDomain_NotVerified, EventSystemUser.DomainVerification); - _logger.LogInformation(Constants.BypassFiltersEventId, + logger.LogInformation(Constants.BypassFiltersEventId, "Verification for organization {OrgId} with domain {Domain} failed", domainVerificationResult.OrganizationId, domainVerificationResult.DomainName); } - await _organizationDomainRepository.ReplaceAsync(domainVerificationResult); + await organizationDomainRepository.ReplaceAsync(domainVerificationResult); return domainVerificationResult; } - private async Task VerifyOrganizationDomainAsync(OrganizationDomain domain) + private async Task VerifyOrganizationDomainAsync(OrganizationDomain domain, IActingUser actingUser) { domain.SetLastCheckedDate(); if (domain.VerifiedDate is not null) { - await _organizationDomainRepository.ReplaceAsync(domain); + await organizationDomainRepository.ReplaceAsync(domain); throw new ConflictException("Domain has already been verified."); } var claimedDomain = - await _organizationDomainRepository.GetClaimedDomainsByDomainNameAsync(domain.DomainName); + await organizationDomainRepository.GetClaimedDomainsByDomainNameAsync(domain.DomainName); if (claimedDomain.Count > 0) { - await _organizationDomainRepository.ReplaceAsync(domain); + await organizationDomainRepository.ReplaceAsync(domain); throw new ConflictException("The domain is not available to be claimed."); } try { - if (await _dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt)) + if (await dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt)) { domain.SetVerifiedDate(); - await EnableSingleOrganizationPolicyAsync(domain.OrganizationId); + await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser); } } catch (Exception e) { - _logger.LogError("Error verifying Organization domain: {domain}. {errorMessage}", + logger.LogError("Error verifying Organization domain: {domain}. {errorMessage}", domain.DomainName, e.Message); } return domain; } - private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId) + private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser) { - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) + if (featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) { - await _policyService.SaveAsync( - new Policy { OrganizationId = organizationId, Type = PolicyType.SingleOrg, Enabled = true }, null); + await policyService.SaveAsync( + new Policy { OrganizationId = organizationId, Type = PolicyType.SingleOrg, Enabled = true }, + savingUserId: actingUser is StandardUser standardUser ? standardUser.UserId : null, + eventSystemUser: actingUser is SystemUser systemUser ? systemUser.SystemUserType : null); } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeNonCompliantOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeNonCompliantOrganizationUserCommand.cs new file mode 100644 index 0000000000..c9768a8905 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeNonCompliantOrganizationUserCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; +using Bit.Core.Models.Commands; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; + +public interface IRevokeNonCompliantOrganizationUserCommand +{ + Task RevokeNonCompliantOrganizationUsersAsync(RevokeOrganizationUsersRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/RevokeOrganizationUserRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/RevokeOrganizationUserRequest.cs new file mode 100644 index 0000000000..88f1dc8aa1 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Requests/RevokeOrganizationUserRequest.cs @@ -0,0 +1,13 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; + +public record RevokeOrganizationUsersRequest( + Guid OrganizationId, + IEnumerable OrganizationUsers, + IActingUser ActionPerformedBy) +{ + public RevokeOrganizationUsersRequest(Guid organizationId, OrganizationUserUserDetails organizationUser, IActingUser actionPerformedBy) + : this(organizationId, [organizationUser], actionPerformedBy) { } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs new file mode 100644 index 0000000000..971ed02b29 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs @@ -0,0 +1,112 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; +using Bit.Core.Enums; +using Bit.Core.Models.Commands; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; + +public class RevokeNonCompliantOrganizationUserCommand(IOrganizationUserRepository organizationUserRepository, + IEventService eventService, + IHasConfirmedOwnersExceptQuery confirmedOwnersExceptQuery, + TimeProvider timeProvider) : IRevokeNonCompliantOrganizationUserCommand +{ + public const string ErrorCannotRevokeSelf = "You cannot revoke yourself."; + public const string ErrorOnlyOwnersCanRevokeOtherOwners = "Only owners can revoke other owners."; + public const string ErrorUserAlreadyRevoked = "User is already revoked."; + public const string ErrorOrgMustHaveAtLeastOneOwner = "Organization must have at least one confirmed owner."; + public const string ErrorInvalidUsers = "Invalid users."; + public const string ErrorRequestedByWasNotValid = "Action was performed by an unexpected type."; + + public async Task RevokeNonCompliantOrganizationUsersAsync(RevokeOrganizationUsersRequest request) + { + var validationResult = await ValidateAsync(request); + + if (validationResult.HasErrors) + { + return validationResult; + } + + await organizationUserRepository.RevokeManyByIdAsync(request.OrganizationUsers.Select(x => x.Id)); + + var now = timeProvider.GetUtcNow(); + + switch (request.ActionPerformedBy) + { + case StandardUser: + await eventService.LogOrganizationUserEventsAsync( + request.OrganizationUsers.Select(x => GetRevokedUserEventTuple(x, now))); + break; + case SystemUser { SystemUserType: not null } loggableSystem: + await eventService.LogOrganizationUserEventsAsync( + request.OrganizationUsers.Select(x => + GetRevokedUserEventBySystemUserTuple(x, loggableSystem.SystemUserType.Value, now))); + break; + } + + return validationResult; + } + + private static (OrganizationUserUserDetails organizationUser, EventType eventType, DateTime? time) GetRevokedUserEventTuple( + OrganizationUserUserDetails organizationUser, DateTimeOffset dateTimeOffset) => + new(organizationUser, EventType.OrganizationUser_Revoked, dateTimeOffset.UtcDateTime); + + private static (OrganizationUserUserDetails organizationUser, EventType eventType, EventSystemUser eventSystemUser, DateTime? time) GetRevokedUserEventBySystemUserTuple( + OrganizationUserUserDetails organizationUser, EventSystemUser systemUser, DateTimeOffset dateTimeOffset) => new(organizationUser, + EventType.OrganizationUser_Revoked, systemUser, dateTimeOffset.UtcDateTime); + + private async Task ValidateAsync(RevokeOrganizationUsersRequest request) + { + if (!PerformedByIsAnExpectedType(request.ActionPerformedBy)) + { + return new CommandResult(ErrorRequestedByWasNotValid); + } + + if (request.ActionPerformedBy is StandardUser user + && request.OrganizationUsers.Any(x => x.UserId == user.UserId)) + { + return new CommandResult(ErrorCannotRevokeSelf); + } + + if (request.OrganizationUsers.Any(x => x.OrganizationId != request.OrganizationId)) + { + return new CommandResult(ErrorInvalidUsers); + } + + if (!await confirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync( + request.OrganizationId, + request.OrganizationUsers.Select(x => x.Id))) + { + return new CommandResult(ErrorOrgMustHaveAtLeastOneOwner); + } + + return request.OrganizationUsers.Aggregate(new CommandResult(), (result, userToRevoke) => + { + if (IsAlreadyRevoked(userToRevoke)) + { + result.ErrorMessages.Add($"{ErrorUserAlreadyRevoked} Id: {userToRevoke.Id}"); + return result; + } + + if (NonOwnersCannotRevokeOwners(userToRevoke, request.ActionPerformedBy)) + { + result.ErrorMessages.Add($"{ErrorOnlyOwnersCanRevokeOtherOwners}"); + return result; + } + + return result; + }); + } + + private static bool PerformedByIsAnExpectedType(IActingUser entity) => entity is SystemUser or StandardUser; + + private static bool IsAlreadyRevoked(OrganizationUserUserDetails organizationUser) => + organizationUser is { Status: OrganizationUserStatusType.Revoked }; + + private static bool NonOwnersCannotRevokeOwners(OrganizationUserUserDetails organizationUser, + IActingUser actingUser) => + actingUser is StandardUser { IsOrganizationOwnerOrProvider: false } && organizationUser.Type == OrganizationUserType.Owner; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs index 117a7ec733..d1a52f0080 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs @@ -1,6 +1,7 @@ #nullable enable using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.Utilities; @@ -15,6 +16,7 @@ public record PolicyUpdate public PolicyType Type { get; set; } public string? Data { get; set; } public bool Enabled { get; set; } + public IActingUser? PerformedBy { get; set; } public T GetDataModel() where T : IPolicyDataModel, new() { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs index cc6971f946..050949ee7f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidator.cs @@ -2,8 +2,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; @@ -18,6 +20,8 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; public class SingleOrgPolicyValidator : IPolicyValidator { public PolicyType Type => PolicyType.SingleOrg; + private const string OrganizationNotFoundErrorMessage = "Organization not found."; + private const string ClaimedDomainSingleOrganizationRequiredErrorMessage = "The Single organization policy is required for organizations that have enabled domain verification."; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IMailService _mailService; @@ -27,6 +31,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator private readonly IFeatureService _featureService; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; + private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand; public SingleOrgPolicyValidator( IOrganizationUserRepository organizationUserRepository, @@ -36,7 +41,8 @@ public class SingleOrgPolicyValidator : IPolicyValidator ICurrentContext currentContext, IFeatureService featureService, IRemoveOrganizationUserCommand removeOrganizationUserCommand, - IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery) + IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, + IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand) { _organizationUserRepository = organizationUserRepository; _mailService = mailService; @@ -46,6 +52,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator _featureService = featureService; _removeOrganizationUserCommand = removeOrganizationUserCommand; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; + _revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand; } public IEnumerable RequiredPolicies => []; @@ -54,10 +61,54 @@ public class SingleOrgPolicyValidator : IPolicyValidator { if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) { - await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); + if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) + { + var currentUser = _currentContext.UserId ?? Guid.Empty; + var isOwnerOrProvider = await _currentContext.OrganizationOwner(policyUpdate.OrganizationId); + await RevokeNonCompliantUsersAsync(policyUpdate.OrganizationId, policyUpdate.PerformedBy ?? new StandardUser(currentUser, isOwnerOrProvider)); + } + else + { + await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); + } } } + private async Task RevokeNonCompliantUsersAsync(Guid organizationId, IActingUser performedBy) + { + var organization = await _organizationRepository.GetByIdAsync(organizationId); + + if (organization is null) + { + throw new NotFoundException(OrganizationNotFoundErrorMessage); + } + + var currentActiveRevocableOrganizationUsers = + (await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId)) + .Where(ou => ou.Status != OrganizationUserStatusType.Invited && + ou.Status != OrganizationUserStatusType.Revoked && + ou.Type != OrganizationUserType.Owner && + ou.Type != OrganizationUserType.Admin && + !(performedBy is StandardUser stdUser && stdUser.UserId == ou.UserId)) + .ToList(); + + if (currentActiveRevocableOrganizationUsers.Count == 0) + { + return; + } + + var commandResult = await _revokeNonCompliantOrganizationUserCommand.RevokeNonCompliantOrganizationUsersAsync( + new RevokeOrganizationUsersRequest(organizationId, currentActiveRevocableOrganizationUsers, performedBy)); + + if (commandResult.HasErrors) + { + throw new BadRequestException(string.Join(", ", commandResult.ErrorMessages)); + } + + await Task.WhenAll(currentActiveRevocableOrganizationUsers.Select(x => + _mailService.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), x.Email))); + } + private async Task RemoveNonCompliantUsersAsync(Guid organizationId) { // Remove non-compliant users @@ -67,7 +118,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator var org = await _organizationRepository.GetByIdAsync(organizationId); if (org == null) { - throw new NotFoundException("Organization not found."); + throw new NotFoundException(OrganizationNotFoundErrorMessage); } var removableOrgUsers = orgUsers.Where(ou => @@ -76,18 +127,17 @@ public class SingleOrgPolicyValidator : IPolicyValidator ou.Type != OrganizationUserType.Owner && ou.Type != OrganizationUserType.Admin && ou.UserId != savingUserId - ).ToList(); + ).ToList(); var userOrgs = await _organizationUserRepository.GetManyByManyUsersAsync( - removableOrgUsers.Select(ou => ou.UserId!.Value)); + removableOrgUsers.Select(ou => ou.UserId!.Value)); foreach (var orgUser in removableOrgUsers) { if (userOrgs.Any(ou => ou.UserId == orgUser.UserId - && ou.OrganizationId != org.Id - && ou.Status != OrganizationUserStatusType.Invited)) + && ou.OrganizationId != org.Id + && ou.Status != OrganizationUserStatusType.Invited)) { - await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id, - savingUserId); + await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, orgUser.Id, savingUserId); await _mailService.SendOrganizationUserRemovedForPolicySingleOrgEmailAsync( org.DisplayName(), orgUser.Email); @@ -111,7 +161,7 @@ public class SingleOrgPolicyValidator : IPolicyValidator if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)) { - return "The Single organization policy is required for organizations that have enabled domain verification."; + return ClaimedDomainSingleOrganizationRequiredErrorMessage; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs index ef896bbb9b..c2dd8cff91 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidator.cs @@ -2,12 +2,15 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; @@ -21,6 +24,10 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator private readonly ICurrentContext _currentContext; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; + private readonly IFeatureService _featureService; + private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand; + + public const string NonCompliantMembersWillLoseAccessMessage = "Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page."; public PolicyType Type => PolicyType.TwoFactorAuthentication; public IEnumerable RequiredPolicies => []; @@ -31,7 +38,9 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator IOrganizationRepository organizationRepository, ICurrentContext currentContext, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IRemoveOrganizationUserCommand removeOrganizationUserCommand) + IRemoveOrganizationUserCommand removeOrganizationUserCommand, + IFeatureService featureService, + IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand) { _organizationUserRepository = organizationUserRepository; _mailService = mailService; @@ -39,16 +48,65 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator _currentContext = currentContext; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; + _featureService = featureService; + _revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand; } public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) { if (currentPolicy is not { Enabled: true } && policyUpdate is { Enabled: true }) { - await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); + if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) + { + var currentUser = _currentContext.UserId ?? Guid.Empty; + var isOwnerOrProvider = await _currentContext.OrganizationOwner(policyUpdate.OrganizationId); + await RevokeNonCompliantUsersAsync(policyUpdate.OrganizationId, policyUpdate.PerformedBy ?? new StandardUser(currentUser, isOwnerOrProvider)); + } + else + { + await RemoveNonCompliantUsersAsync(policyUpdate.OrganizationId); + } } } + private async Task RevokeNonCompliantUsersAsync(Guid organizationId, IActingUser performedBy) + { + var organization = await _organizationRepository.GetByIdAsync(organizationId); + + var currentActiveRevocableOrganizationUsers = + (await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId)) + .Where(ou => ou.Status != OrganizationUserStatusType.Invited && + ou.Status != OrganizationUserStatusType.Revoked && + ou.Type != OrganizationUserType.Owner && + ou.Type != OrganizationUserType.Admin && + !(performedBy is StandardUser stdUser && stdUser.UserId == ou.UserId)) + .ToList(); + + if (currentActiveRevocableOrganizationUsers.Count == 0) + { + return; + } + + var organizationUsersTwoFactorEnabled = + await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(currentActiveRevocableOrganizationUsers); + + if (NonCompliantMembersWillLoseAccess(currentActiveRevocableOrganizationUsers, organizationUsersTwoFactorEnabled)) + { + throw new BadRequestException(NonCompliantMembersWillLoseAccessMessage); + } + + var commandResult = await _revokeNonCompliantOrganizationUserCommand.RevokeNonCompliantOrganizationUsersAsync( + new RevokeOrganizationUsersRequest(organizationId, currentActiveRevocableOrganizationUsers, performedBy)); + + if (commandResult.HasErrors) + { + throw new BadRequestException(string.Join(", ", commandResult.ErrorMessages)); + } + + await Task.WhenAll(currentActiveRevocableOrganizationUsers.Select(x => + _mailService.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), x.Email))); + } + private async Task RemoveNonCompliantUsersAsync(Guid organizationId) { var org = await _organizationRepository.GetByIdAsync(organizationId); @@ -83,5 +141,12 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator } } + private static bool NonCompliantMembersWillLoseAccess( + IEnumerable orgUserDetails, + IEnumerable<(OrganizationUserUserDetails user, bool isTwoFactorEnabled)> organizationUsersTwoFactorEnabled) => + orgUserDetails.Any(x => + !x.HasMasterPassword && !organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == x.Id) + .isTwoFactorEnabled); + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); } diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index cb540c212b..516b4614af 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -58,4 +58,6 @@ public interface IOrganizationUserRepository : IRepository Task> GetManyByOrganizationWithClaimedDomainsAsync(Guid organizationId); + + Task RevokeManyByIdAsync(IEnumerable organizationUserIds); } diff --git a/src/Core/AdminConsole/Services/IPolicyService.cs b/src/Core/AdminConsole/Services/IPolicyService.cs index 16ff2f4fa1..715c3a34d9 100644 --- a/src/Core/AdminConsole/Services/IPolicyService.cs +++ b/src/Core/AdminConsole/Services/IPolicyService.cs @@ -9,7 +9,7 @@ namespace Bit.Core.AdminConsole.Services; public interface IPolicyService { - Task SaveAsync(Policy policy, Guid? savingUserId); + Task SaveAsync(Policy policy, Guid? savingUserId, EventSystemUser? eventSystemUser = null); /// /// Get the combined master password policy options for the specified user. diff --git a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs index 42655040a3..69ec27fd8a 100644 --- a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs +++ b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs @@ -1,5 +1,6 @@ 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.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; @@ -9,6 +10,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -34,6 +36,7 @@ public class PolicyService : IPolicyService private readonly ISavePolicyCommand _savePolicyCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; + private readonly ICurrentContext _currentContext; public PolicyService( IApplicationCacheService applicationCacheService, @@ -48,7 +51,8 @@ public class PolicyService : IPolicyService IFeatureService featureService, ISavePolicyCommand savePolicyCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand, - IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery) + IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, + ICurrentContext currentContext) { _applicationCacheService = applicationCacheService; _eventService = eventService; @@ -63,19 +67,24 @@ public class PolicyService : IPolicyService _savePolicyCommand = savePolicyCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; + _currentContext = currentContext; } - public async Task SaveAsync(Policy policy, Guid? savingUserId) + public async Task SaveAsync(Policy policy, Guid? savingUserId, EventSystemUser? eventSystemUser = null) { if (_featureService.IsEnabled(FeatureFlagKeys.Pm13322AddPolicyDefinitions)) { // Transitional mapping - this will be moved to callers once the feature flag is removed + // TODO make sure to populate with SystemUser if not an actual user var policyUpdate = new PolicyUpdate { OrganizationId = policy.OrganizationId, Type = policy.Type, Enabled = policy.Enabled, - Data = policy.Data + Data = policy.Data, + PerformedBy = savingUserId.HasValue + ? new StandardUser(savingUserId.Value, await _currentContext.OrganizationOwner(policy.OrganizationId)) + : new SystemUser(eventSystemUser ?? EventSystemUser.Unknown) }; await _savePolicyCommand.SaveAsync(policyUpdate); diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.html.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.html.hbs new file mode 100644 index 0000000000..d04abe86c9 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.html.hbs @@ -0,0 +1,14 @@ +{{#>FullHtmlLayout}} + + + + + + + +
+ Your user account has been revoked from the {{OrganizationName}} organization because your account is part of multiple organizations. Before you can re-join {{OrganizationName}}, you must first leave all other organizations. +
+ To leave an organization, first log into the web app, select the three dot menu next to the organization name, and select Leave. +
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.text.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.text.hbs new file mode 100644 index 0000000000..f933e8cf62 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForSingleOrgPolicy.text.hbs @@ -0,0 +1,5 @@ +{{#>BasicTextLayout}} +Your user account has been revoked from the {{OrganizationName}} organization because your account is part of multiple organizations. Before you can rejoin {{OrganizationName}}, you must first leave all other organizations. + +To leave an organization, first log in the web app (https://vault.bitwarden.com/#/login), select the three dot menu next to the organization name, and select Leave. +{{/BasicTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForTwoFactorPolicy.html.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForTwoFactorPolicy.html.hbs new file mode 100644 index 0000000000..cf38632a9e --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForTwoFactorPolicy.html.hbs @@ -0,0 +1,15 @@ +{{#>FullHtmlLayout}} + + + + + + + +
+ Your user account has been revoked from the {{OrganizationName}} organization because you do not have two-step login configured. Before you can re-join {{OrganizationName}}, you need to set up two-step login on your user account. +
+ Learn how to enable two-step login on your user account at + https://help.bitwarden.com/article/setup-two-step-login/ +
+{{/FullHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForTwoFactorPolicy.text.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForTwoFactorPolicy.text.hbs new file mode 100644 index 0000000000..f197f37f00 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/OrganizationUserRevokedForTwoFactorPolicy.text.hbs @@ -0,0 +1,7 @@ +{{#>BasicTextLayout}} + Your user account has been removed from the {{OrganizationName}} organization because you do not have two-step login + configured. Before you can re-join this organization you need to set up two-step login on your user account. + + Learn how to enable two-step login on your user account at + https://help.bitwarden.com/article/setup-two-step-login/ +{{/BasicTextLayout}} diff --git a/src/Core/Models/Commands/CommandResult.cs b/src/Core/Models/Commands/CommandResult.cs new file mode 100644 index 0000000000..4ac2d62499 --- /dev/null +++ b/src/Core/Models/Commands/CommandResult.cs @@ -0,0 +1,12 @@ +namespace Bit.Core.Models.Commands; + +public class CommandResult(IEnumerable errors) +{ + public CommandResult(string error) : this([error]) { } + + public bool Success => ErrorMessages.Count == 0; + public bool HasErrors => ErrorMessages.Count > 0; + public List ErrorMessages { get; } = errors.ToList(); + + public CommandResult() : this([]) { } +} diff --git a/src/Core/Models/Mail/OrganizationUserRevokedForPolicySingleOrgViewModel.cs b/src/Core/Models/Mail/OrganizationUserRevokedForPolicySingleOrgViewModel.cs new file mode 100644 index 0000000000..27c784bd15 --- /dev/null +++ b/src/Core/Models/Mail/OrganizationUserRevokedForPolicySingleOrgViewModel.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Models.Mail; + +public class OrganizationUserRevokedForPolicySingleOrgViewModel : BaseMailModel +{ + public string OrganizationName { get; set; } +} diff --git a/src/Core/Models/Mail/OrganizationUserRevokedForPolicyTwoFactorViewModel.cs b/src/Core/Models/Mail/OrganizationUserRevokedForPolicyTwoFactorViewModel.cs new file mode 100644 index 0000000000..9286ee74b3 --- /dev/null +++ b/src/Core/Models/Mail/OrganizationUserRevokedForPolicyTwoFactorViewModel.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Models.Mail; + +public class OrganizationUserRevokedForPolicyTwoFactorViewModel : BaseMailModel +{ + public string OrganizationName { get; set; } +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index d11da2119a..96fdefcfad 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -91,6 +91,7 @@ public static class OrganizationServiceCollectionExtensions private static void AddOrganizationUserCommands(this IServiceCollection services) { 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 5514cd507d..bc8d1440f1 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -35,6 +35,8 @@ public interface IMailService Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable adminEmails, bool hasAccessSecretsManager = false); Task SendOrganizationConfirmedEmailAsync(string organizationName, string email, bool hasAccessSecretsManager = false); Task SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(string organizationName, string email); + Task SendOrganizationUserRevokedForTwoFactoryPolicyEmailAsync(string organizationName, string email); + Task SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(string organizationName, string email); Task SendPasswordlessSignInAsync(string returnUrl, string token, string email); Task SendInvoiceUpcoming( string email, diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index e1943b0e3c..acc729e53c 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -25,8 +25,7 @@ public class HandlebarsMailService : IMailService private readonly GlobalSettings _globalSettings; private readonly IMailDeliveryService _mailDeliveryService; private readonly IMailEnqueuingService _mailEnqueuingService; - private readonly Dictionary> _templateCache = - new Dictionary>(); + private readonly Dictionary> _templateCache = new(); private bool _registeredHelpersAndPartials = false; @@ -295,6 +294,20 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendOrganizationUserRevokedForTwoFactoryPolicyEmailAsync(string organizationName, string email) + { + var message = CreateDefaultMessage($"You have been revoked from {organizationName}", email); + var model = new OrganizationUserRevokedForPolicyTwoFactorViewModel + { + OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName + }; + await AddMessageContentAsync(message, "AdminConsole.OrganizationUserRevokedForTwoFactorPolicy", model); + message.Category = "OrganizationUserRevokedForTwoFactorPolicy"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendWelcomeEmailAsync(User user) { var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email); @@ -496,6 +509,20 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(string organizationName, string email) + { + var message = CreateDefaultMessage($"You have been revoked from {organizationName}", email); + var model = new OrganizationUserRevokedForPolicySingleOrgViewModel + { + OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName + }; + await AddMessageContentAsync(message, "AdminConsole.OrganizationUserRevokedForSingleOrgPolicy", model); + message.Category = "OrganizationUserRevokedForSingleOrgPolicy"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage) { var message = CreateDefaultMessage(queueMessage.Subject, queueMessage.ToEmails); diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index a56858fb96..399874eee7 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -79,6 +79,12 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendOrganizationUserRevokedForTwoFactoryPolicyEmailAsync(string organizationName, string email) => + Task.CompletedTask; + + public Task SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(string organizationName, string email) => + Task.CompletedTask; + public Task SendTwoFactorEmailAsync(string email, string token) { return Task.FromResult(0); diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index d5bdd3b6a2..42f79852f3 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -557,4 +557,14 @@ public class OrganizationUserRepository : Repository, IO return results.ToList(); } } + + public async Task RevokeManyByIdAsync(IEnumerable organizationUserIds) + { + await using var connection = new SqlConnection(ConnectionString); + + await connection.ExecuteAsync( + "[dbo].[OrganizationUser_SetStatusForUsersById]", + new { OrganizationUserIds = JsonSerializer.Serialize(organizationUserIds), Status = OrganizationUserStatusType.Revoked }, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index a64c19704d..007ff1a7ff 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -721,4 +721,16 @@ public class OrganizationUserRepository : Repository organizationUserIds) + { + using var scope = ServiceScopeFactory.CreateScope(); + + var dbContext = GetDatabaseContext(scope); + + await dbContext.OrganizationUsers.Where(x => organizationUserIds.Contains(x.Id)) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, OrganizationUserStatusType.Revoked)); + + await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdsAsync(organizationUserIds); + } } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersById.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersById.sql new file mode 100644 index 0000000000..95ed5a3155 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_SetStatusForUsersById.sql @@ -0,0 +1,29 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_SetStatusForUsersById] + @OrganizationUserIds AS NVARCHAR(MAX), + @Status SMALLINT +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(@OrganizationUserIds); + + -- Check if the input table is empty + IF (SELECT COUNT(1) FROM @ParsedIds) < 1 + BEGIN + RETURN(-1); + END + + UPDATE + [dbo].[OrganizationUser] + SET [Status] = @Status + WHERE [Id] IN (SELECT Id from @ParsedIds) + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @OrganizationUserIds +END + diff --git a/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs index dff9b57178..794f6fddf3 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs @@ -2,6 +2,7 @@ using AutoFixture; using AutoFixture.Xunit2; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; namespace Bit.Core.Test.AdminConsole.AutoFixture; @@ -12,7 +13,8 @@ internal class PolicyUpdateCustomization(PolicyType type, bool enabled) : ICusto { fixture.Customize(composer => composer .With(o => o.Type, type) - .With(o => o.Enabled, enabled)); + .With(o => o.Enabled, enabled) + .With(o => o.PerformedBy, new StandardUser(Guid.NewGuid(), false))); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs index 2fcaf8134c..8dbe533131 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.Services; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -28,7 +29,12 @@ public class VerifyOrganizationDomainCommandTests DomainName = "Test Domain", Txt = "btw+test18383838383" }; + + sutProvider.GetDependency() + .UserId.Returns(Guid.NewGuid()); + expected.SetVerifiedDate(); + sutProvider.GetDependency() .GetByIdAsync(id) .Returns(expected); @@ -53,6 +59,10 @@ public class VerifyOrganizationDomainCommandTests sutProvider.GetDependency() .GetByIdAsync(id) .Returns(expected); + + sutProvider.GetDependency() + .UserId.Returns(Guid.NewGuid()); + sutProvider.GetDependency() .GetClaimedDomainsByDomainNameAsync(expected.DomainName) .Returns(new List { expected }); @@ -77,9 +87,14 @@ public class VerifyOrganizationDomainCommandTests sutProvider.GetDependency() .GetByIdAsync(id) .Returns(expected); + + sutProvider.GetDependency() + .UserId.Returns(Guid.NewGuid()); + sutProvider.GetDependency() .GetClaimedDomainsByDomainNameAsync(expected.DomainName) .Returns(new List()); + sutProvider.GetDependency() .ResolveAsync(expected.DomainName, Arg.Any()) .Returns(true); @@ -107,9 +122,14 @@ public class VerifyOrganizationDomainCommandTests sutProvider.GetDependency() .GetByIdAsync(id) .Returns(expected); + + sutProvider.GetDependency() + .UserId.Returns(Guid.NewGuid()); + sutProvider.GetDependency() .GetClaimedDomainsByDomainNameAsync(expected.DomainName) .Returns(new List()); + sutProvider.GetDependency() .ResolveAsync(expected.DomainName, Arg.Any()) .Returns(false); @@ -143,7 +163,7 @@ public class VerifyOrganizationDomainCommandTests [Theory, BitAutoData] public async Task UserVerifyOrganizationDomainAsync_GivenOrganizationDomainWithAccountDeprovisioningEnabled_WhenDomainIsVerified_ThenSingleOrgPolicyShouldBeEnabled( - OrganizationDomain domain, SutProvider sutProvider) + OrganizationDomain domain, Guid userId, SutProvider sutProvider) { sutProvider.GetDependency() .GetClaimedDomainsByDomainNameAsync(domain.DomainName) @@ -157,11 +177,14 @@ public class VerifyOrganizationDomainCommandTests .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) .Returns(true); + sutProvider.GetDependency() + .UserId.Returns(userId); + _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); await sutProvider.GetDependency() .Received(1) - .SaveAsync(Arg.Is(x => x.Type == PolicyType.SingleOrg && x.OrganizationId == domain.OrganizationId && x.Enabled), null); + .SaveAsync(Arg.Is(x => x.Type == PolicyType.SingleOrg && x.OrganizationId == domain.OrganizationId && x.Enabled), userId); } [Theory, BitAutoData] @@ -176,6 +199,9 @@ public class VerifyOrganizationDomainCommandTests .ResolveAsync(domain.DomainName, domain.Txt) .Returns(true); + sutProvider.GetDependency() + .UserId.Returns(Guid.NewGuid()); + sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) .Returns(false); @@ -189,7 +215,7 @@ public class VerifyOrganizationDomainCommandTests [Theory, BitAutoData] public async Task UserVerifyOrganizationDomainAsync_GivenOrganizationDomainWithAccountDeprovisioningEnabled_WhenDomainIsNotVerified_ThenSingleOrgPolicyShouldNotBeEnabled( - OrganizationDomain domain, SutProvider sutProvider) + OrganizationDomain domain, SutProvider sutProvider) { sutProvider.GetDependency() .GetClaimedDomainsByDomainNameAsync(domain.DomainName) @@ -199,6 +225,9 @@ public class VerifyOrganizationDomainCommandTests .ResolveAsync(domain.DomainName, domain.Txt) .Returns(false); + sutProvider.GetDependency() + .UserId.Returns(Guid.NewGuid()); + sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) .Returns(true); @@ -223,6 +252,9 @@ public class VerifyOrganizationDomainCommandTests .ResolveAsync(domain.DomainName, domain.Txt) .Returns(false); + sutProvider.GetDependency() + .UserId.Returns(Guid.NewGuid()); + sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) .Returns(true); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommandTests.cs new file mode 100644 index 0000000000..3653cd27d7 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommandTests.cs @@ -0,0 +1,185 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; +using Bit.Core.Enums; +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; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; + +[SutProviderCustomize] +public class RevokeNonCompliantOrganizationUserCommandTests +{ + [Theory, BitAutoData] + public async Task RevokeNonCompliantOrganizationUsersAsync_GivenUnrecognizedUserType_WhenAttemptingToRevoke_ThenErrorShouldBeReturned( + Guid organizationId, SutProvider sutProvider) + { + var command = new RevokeOrganizationUsersRequest(organizationId, [], new InvalidUser()); + + var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command); + + Assert.True(result.HasErrors); + Assert.Contains(RevokeNonCompliantOrganizationUserCommand.ErrorRequestedByWasNotValid, result.ErrorMessages); + } + + [Theory, BitAutoData] + public async Task RevokeNonCompliantOrganizationUsersAsync_GivenPopulatedRequest_WhenUserAttemptsToRevokeThemselves_ThenErrorShouldBeReturned( + Guid organizationId, OrganizationUserUserDetails revokingUser, + SutProvider sutProvider) + { + var command = new RevokeOrganizationUsersRequest(organizationId, revokingUser, + new StandardUser(revokingUser?.UserId ?? Guid.NewGuid(), true)); + + var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command); + + Assert.True(result.HasErrors); + Assert.Contains(RevokeNonCompliantOrganizationUserCommand.ErrorCannotRevokeSelf, result.ErrorMessages); + } + + [Theory, BitAutoData] + public async Task RevokeNonCompliantOrganizationUsersAsync_GivenPopulatedRequest_WhenUserAttemptsToRevokeOrgUsersFromAnotherOrg_ThenErrorShouldBeReturned( + Guid organizationId, OrganizationUserUserDetails userFromAnotherOrg, + SutProvider sutProvider) + { + userFromAnotherOrg.OrganizationId = Guid.NewGuid(); + + var command = new RevokeOrganizationUsersRequest(organizationId, userFromAnotherOrg, + new StandardUser(Guid.NewGuid(), true)); + + var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command); + + Assert.True(result.HasErrors); + Assert.Contains(RevokeNonCompliantOrganizationUserCommand.ErrorInvalidUsers, result.ErrorMessages); + } + + [Theory, BitAutoData] + public async Task RevokeNonCompliantOrganizationUsersAsync_GivenPopulatedRequest_WhenUserAttemptsToRevokeAllOwnersFromOrg_ThenErrorShouldBeReturned( + Guid organizationId, OrganizationUserUserDetails userToRevoke, + SutProvider sutProvider) + { + userToRevoke.OrganizationId = organizationId; + + var command = new RevokeOrganizationUsersRequest(organizationId, userToRevoke, + new StandardUser(Guid.NewGuid(), true)); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(false); + + var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command); + + Assert.True(result.HasErrors); + Assert.Contains(RevokeNonCompliantOrganizationUserCommand.ErrorOrgMustHaveAtLeastOneOwner, result.ErrorMessages); + } + + [Theory, BitAutoData] + public async Task RevokeNonCompliantOrganizationUsersAsync_GivenPopulatedRequest_WhenUserAttemptsToRevokeOwnerWhenNotAnOwner_ThenErrorShouldBeReturned( + Guid organizationId, OrganizationUserUserDetails userToRevoke, + SutProvider sutProvider) + { + userToRevoke.OrganizationId = organizationId; + userToRevoke.Type = OrganizationUserType.Owner; + + var command = new RevokeOrganizationUsersRequest(organizationId, userToRevoke, + new StandardUser(Guid.NewGuid(), false)); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(true); + + var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command); + + Assert.True(result.HasErrors); + Assert.Contains(RevokeNonCompliantOrganizationUserCommand.ErrorOnlyOwnersCanRevokeOtherOwners, result.ErrorMessages); + } + + [Theory, BitAutoData] + public async Task RevokeNonCompliantOrganizationUsersAsync_GivenPopulatedRequest_WhenUserAttemptsToRevokeUserWhoIsAlreadyRevoked_ThenErrorShouldBeReturned( + Guid organizationId, OrganizationUserUserDetails userToRevoke, + SutProvider sutProvider) + { + userToRevoke.OrganizationId = organizationId; + userToRevoke.Status = OrganizationUserStatusType.Revoked; + + var command = new RevokeOrganizationUsersRequest(organizationId, userToRevoke, + new StandardUser(Guid.NewGuid(), true)); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(true); + + var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command); + + Assert.True(result.HasErrors); + Assert.Contains($"{RevokeNonCompliantOrganizationUserCommand.ErrorUserAlreadyRevoked} Id: {userToRevoke.Id}", result.ErrorMessages); + } + + [Theory, BitAutoData] + public async Task RevokeNonCompliantOrganizationUsersAsync_GivenPopulatedRequest_WhenUserHasMultipleInvalidUsers_ThenErrorShouldBeReturned( + Guid organizationId, IEnumerable usersToRevoke, + SutProvider sutProvider) + { + var revocableUsers = usersToRevoke.ToList(); + revocableUsers.ForEach(user => user.OrganizationId = organizationId); + revocableUsers[0].Type = OrganizationUserType.Owner; + revocableUsers[1].Status = OrganizationUserStatusType.Revoked; + + var command = new RevokeOrganizationUsersRequest(organizationId, revocableUsers, + new StandardUser(Guid.NewGuid(), false)); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(true); + + var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command); + + Assert.True(result.HasErrors); + Assert.True(result.ErrorMessages.Count > 1); + } + + [Theory, BitAutoData] + public async Task RevokeNonCompliantOrganizationUsersAsync_GivenValidPopulatedRequest_WhenUserAttemptsToRevokeAUser_ThenUserShouldBeRevoked( + Guid organizationId, OrganizationUserUserDetails userToRevoke, + SutProvider sutProvider) + { + userToRevoke.OrganizationId = organizationId; + userToRevoke.Type = OrganizationUserType.Admin; + + var command = new RevokeOrganizationUsersRequest(organizationId, userToRevoke, + new StandardUser(Guid.NewGuid(), false)); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(organizationId, Arg.Any>()) + .Returns(true); + + var result = await sutProvider.Sut.RevokeNonCompliantOrganizationUsersAsync(command); + + await sutProvider.GetDependency() + .Received(1) + .RevokeManyByIdAsync(Arg.Any>()); + + Assert.True(result.Success); + + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventsAsync( + Arg.Is>( + x => x.Any(y => + y.organizationUser.Id == userToRevoke.Id && y.eventType == EventType.OrganizationUser_Revoked) + )); + } + + public class InvalidUser : IActingUser + { + public Guid? UserId => Guid.Empty; + public bool IsOrganizationOwnerOrProvider => false; + public EventSystemUser? SystemUserType => null; + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs index 76ee574840..0731920757 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; using Bit.Core.Auth.Entities; @@ -10,6 +11,7 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.Commands; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; @@ -61,6 +63,79 @@ public class SingleOrgPolicyValidatorTests Assert.True(string.IsNullOrEmpty(result)); } + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_RevokesNonCompliantUsers( + [PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy policy, + Guid savingUserId, + Guid nonCompliantUserId, + Organization organization, SutProvider sutProvider) + { + policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; + + var compliantUser1 = new OrganizationUserUserDetails + { + OrganizationId = organization.Id, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = new Guid(), + Email = "user1@example.com" + }; + + var compliantUser2 = new OrganizationUserUserDetails + { + OrganizationId = organization.Id, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = new Guid(), + Email = "user2@example.com" + }; + + var nonCompliantUser = new OrganizationUserUserDetails + { + OrganizationId = organization.Id, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = nonCompliantUserId, + Email = "user3@example.com" + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([compliantUser1, compliantUser2, nonCompliantUser]); + + var otherOrganizationUser = new OrganizationUser + { + OrganizationId = new Guid(), + UserId = nonCompliantUserId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Is>(ids => ids.Contains(nonCompliantUserId))) + .Returns([otherOrganizationUser]); + + sutProvider.GetDependency().UserId.Returns(savingUserId); + sutProvider.GetDependency().GetByIdAsync(policyUpdate.OrganizationId).Returns(organization); + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + + sutProvider.GetDependency() + .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()) + .Returns(new CommandResult()); + + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); + + await sutProvider.GetDependency() + .Received(1) + .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()); + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), + "user3@example.com"); + } + [Theory, BitAutoData] public async Task OnSaveSideEffectsAsync_RemovesNonCompliantUsers( [PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate, @@ -116,6 +191,13 @@ public class SingleOrgPolicyValidatorTests sutProvider.GetDependency().UserId.Returns(savingUserId); sutProvider.GetDependency().GetByIdAsync(policyUpdate.OrganizationId).Returns(organization); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(false); + + sutProvider.GetDependency() + .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()) + .Returns(new CommandResult()); + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); await sutProvider.GetDependency() @@ -126,4 +208,73 @@ public class SingleOrgPolicyValidatorTests .SendOrganizationUserRemovedForPolicySingleOrgEmailAsync(organization.DisplayName(), "user3@example.com"); } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_WhenAccountDeprovisioningIsEnabled_ThenUsersAreRevoked( + [PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy policy, + Guid savingUserId, + Guid nonCompliantUserId, + Organization organization, SutProvider sutProvider) + { + policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; + + var compliantUser1 = new OrganizationUserUserDetails + { + OrganizationId = organization.Id, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = new Guid(), + Email = "user1@example.com" + }; + + var compliantUser2 = new OrganizationUserUserDetails + { + OrganizationId = organization.Id, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = new Guid(), + Email = "user2@example.com" + }; + + var nonCompliantUser = new OrganizationUserUserDetails + { + OrganizationId = organization.Id, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = nonCompliantUserId, + Email = "user3@example.com" + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([compliantUser1, compliantUser2, nonCompliantUser]); + + var otherOrganizationUser = new OrganizationUser + { + OrganizationId = new Guid(), + UserId = nonCompliantUserId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Is>(ids => ids.Contains(nonCompliantUserId))) + .Returns([otherOrganizationUser]); + + sutProvider.GetDependency().UserId.Returns(savingUserId); + sutProvider.GetDependency().GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountDeprovisioning).Returns(true); + + sutProvider.GetDependency() + .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()) + .Returns(new CommandResult()); + + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); + + await sutProvider.GetDependency() + .Received() + .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs index 4dce131749..4e5f1816a5 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs @@ -1,12 +1,14 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Commands; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; @@ -176,6 +178,10 @@ public class TwoFactorAuthenticationPolicyValidatorTests HasMasterPassword = false }; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(false); + sutProvider.GetDependency() .GetManyDetailsByOrganizationAsync(policy.OrganizationId) .Returns(new List @@ -201,9 +207,151 @@ public class TwoFactorAuthenticationPolicyValidatorTests var badRequestException = await Assert.ThrowsAsync( () => sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy)); - Assert.Contains("Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(TwoFactorAuthenticationPolicyValidator.NonCompliantMembersWillLoseAccessMessage, badRequestException.Message); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .RemoveUserAsync(organizationId: default, organizationUserId: default, deletingUserId: default); } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_GivenUpdateTo2faPolicy_WhenAccountProvisioningIsDisabled_ThenRevokeUserCommandShouldNotBeCalled( + Organization organization, + [PolicyUpdate(PolicyType.TwoFactorAuthentication)] + PolicyUpdate policyUpdate, + [Policy(PolicyType.TwoFactorAuthentication, false)] + Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(false); + + var orgUserDetailUserAcceptedWithout2Fa = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.User, + Email = "user3@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = true + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns(new List + { + orgUserDetailUserAcceptedWithout2Fa + }); + + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>() + { + (orgUserDetailUserAcceptedWithout2Fa, false), + }); + + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); + + await sutProvider.GetDependency() + .DidNotReceive() + .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_GivenUpdateTo2faPolicy_WhenAccountProvisioningIsEnabledAndUserDoesNotHaveMasterPassword_ThenNonCompliantMembersErrorMessageWillReturn( + Organization organization, + [PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate, + [Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + + var orgUserDetailUserWithout2Fa = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + Email = "user3@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = false + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([orgUserDetailUserWithout2Fa]); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>() + { + (orgUserDetailUserWithout2Fa, false), + }); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy)); + + Assert.Equal(TwoFactorAuthenticationPolicyValidator.NonCompliantMembersWillLoseAccessMessage, exception.Message); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_WhenAccountProvisioningIsEnabledAndUserHasMasterPassword_ThenUserWillBeRevoked( + Organization organization, + [PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate, + [Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy, + SutProvider sutProvider) + { + policy.OrganizationId = organization.Id = policyUpdate.OrganizationId; + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + + var orgUserDetailUserWithout2Fa = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + Email = "user3@test.com", + Name = "TEST", + UserId = Guid.NewGuid(), + HasMasterPassword = true + }; + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([orgUserDetailUserWithout2Fa]); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Any>()) + .Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>() + { + (orgUserDetailUserWithout2Fa, true), + }); + + sutProvider.GetDependency() + .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()) + .Returns(new CommandResult()); + + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, policy); + + await sutProvider.GetDependency() + .Received(1) + .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()); + + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), + "user3@test.com"); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs index 342ede9c82..3ca7004e70 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs @@ -100,7 +100,7 @@ public class SavePolicyCommandTests } [Theory, BitAutoData] - public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest(PolicyUpdate policyUpdate) + public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate) { var sutProvider = SutProviderFactory(); sutProvider.GetDependency() @@ -115,7 +115,7 @@ public class SavePolicyCommandTests } [Theory, BitAutoData] - public async Task SaveAsync_OrganizationCannotUsePolicies_ThrowsBadRequest(PolicyUpdate policyUpdate) + public async Task SaveAsync_OrganizationCannotUsePolicies_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate) { var sutProvider = SutProviderFactory(); sutProvider.GetDependency() diff --git a/test/Core.Test/AdminConsole/Services/EventServiceTests.cs b/test/Core.Test/AdminConsole/Services/EventServiceTests.cs index 18f5371b49..d064fce2ec 100644 --- a/test/Core.Test/AdminConsole/Services/EventServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/EventServiceTests.cs @@ -169,7 +169,6 @@ public class EventServiceTests new EventMessage() { IpAddress = ipAddress, - DeviceType = DeviceType.Server, OrganizationId = orgUser.OrganizationId, UserId = orgUser.UserId, OrganizationUserId = orgUser.Id, diff --git a/util/Migrator/DbScripts/2024-11-26-00_OrgUserSetStatusBulk.sql b/util/Migrator/DbScripts/2024-11-26-00_OrgUserSetStatusBulk.sql new file mode 100644 index 0000000000..95151f90de --- /dev/null +++ b/util/Migrator/DbScripts/2024-11-26-00_OrgUserSetStatusBulk.sql @@ -0,0 +1,28 @@ +CREATE PROCEDURE [dbo].[OrganizationUser_SetStatusForUsersById] + @OrganizationUserIds AS NVARCHAR(MAX), + @Status SMALLINT +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(@OrganizationUserIds); + + -- Check if the input table is empty + IF (SELECT COUNT(1) FROM @ParsedIds) < 1 + BEGIN + RETURN(-1); + END + + UPDATE + [dbo].[OrganizationUser] + SET [Status] = @Status + WHERE [Id] IN (SELECT Id from @ParsedIds) + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationUserIds] @OrganizationUserIds +END From 674bd1e495b343e68174165cebb1f2458efcff0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 27 Nov 2024 12:26:42 +0000 Subject: [PATCH 03/30] [PM-13026] Refactor remove and bulkremove methods to throw error if user is managed by an organization (#5034) * Enhance RemoveOrganizationUserCommand to block removing managed users when account deprovisioning is enabled * Refactor RemoveUsersAsync method to return just the OrgUserId and update related logic. * Refactor RemoveOrganizationUserCommand to improve variable naming and remove unused logging method * Add support for event system user in RemoveUsersAsync method. Refactor unit tests. * Add xmldoc to IRemoveOrganizationUserCommand methods * Refactor RemoveOrganizationUserCommand to use TimeProvider for event date retrieval and update unit tests accordingly * Refactor RemoveOrganizationUserCommand to use constants for error messages * Refactor unit tests to separate feature flag tests * refactor: Update parameter names for clarity in RemoveOrganizationUserCommand * refactor: Rename validation and repository methods for user removal clarity --- .../OrganizationUsersController.cs | 2 +- .../IRemoveOrganizationUserCommand.cs | 49 +- .../RemoveOrganizationUserCommand.cs | 201 +++-- .../RemoveOrganizationUserCommandTests.cs | 777 ++++++++++++++---- 4 files changed, 804 insertions(+), 225 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 3193962fa9..12d11fbc18 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -539,7 +539,7 @@ public class OrganizationUsersController : Controller var userId = _userService.GetProperUserId(User); var result = await _removeOrganizationUserCommand.RemoveUsersAsync(orgId, model.Ids, userId.Value); return new ListResponseModel(result.Select(r => - new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); + new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); } [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRemoveOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRemoveOrganizationUserCommand.cs index 583645a890..7c1cdf05f8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRemoveOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRemoveOrganizationUserCommand.cs @@ -1,14 +1,53 @@ -using Bit.Core.Entities; -using Bit.Core.Enums; +using Bit.Core.Enums; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; public interface IRemoveOrganizationUserCommand { + /// + /// Removes a user from an organization. + /// + /// The ID of the organization. + /// The ID of the user to remove. + Task RemoveUserAsync(Guid organizationId, Guid userId); + + /// + /// Removes a user from an organization with a specified deleting user. + /// + /// The ID of the organization. + /// The ID of the organization user to remove. + /// The ID of the user performing the removal operation. Task RemoveUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId); + /// + /// Removes a user from an organization using a system user. + /// + /// The ID of the organization. + /// The ID of the organization user to remove. + /// The system user performing the removal operation. Task RemoveUserAsync(Guid organizationId, Guid organizationUserId, EventSystemUser eventSystemUser); - Task RemoveUserAsync(Guid organizationId, Guid userId); - Task>> RemoveUsersAsync(Guid organizationId, - IEnumerable organizationUserIds, Guid? deletingUserId); + + /// + /// Removes multiple users from an organization with a specified deleting user. + /// + /// The ID of the organization. + /// The collection of organization user IDs to remove. + /// The ID of the user performing the removal operation. + /// + /// A list of tuples containing the organization user id and the error message for each user that could not be removed, otherwise empty. + /// + Task> RemoveUsersAsync( + Guid organizationId, IEnumerable organizationUserIds, Guid? deletingUserId); + + /// + /// Removes multiple users from an organization using a system user. + /// + /// The ID of the organization. + /// The collection of organization user IDs to remove. + /// The system user performing the removal operation. + /// + /// A list of tuples containing the organization user id and the error message for each user that could not be removed, otherwise empty. + /// + Task> RemoveUsersAsync( + Guid organizationId, IEnumerable organizationUserIds, EventSystemUser eventSystemUser); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs index e6d56ea878..fa027f8e47 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommand.cs @@ -17,6 +17,16 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand private readonly IPushRegistrationService _pushRegistrationService; private readonly ICurrentContext _currentContext; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; + private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery; + private readonly IFeatureService _featureService; + private readonly TimeProvider _timeProvider; + + public const string UserNotFoundErrorMessage = "User not found."; + public const string UsersInvalidErrorMessage = "Users invalid."; + public const string RemoveYourselfErrorMessage = "You cannot remove yourself."; + public const string RemoveOwnerByNonOwnerErrorMessage = "Only owners can delete other owners."; + public const string RemoveLastConfirmedOwnerErrorMessage = "Organization must have at least one confirmed owner."; + public const string RemoveClaimedAccountErrorMessage = "Cannot remove member accounts claimed by the organization. To offboard a member, revoke or delete the account."; public RemoveOrganizationUserCommand( IDeviceRepository deviceRepository, @@ -25,7 +35,10 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand IPushNotificationService pushNotificationService, IPushRegistrationService pushRegistrationService, ICurrentContext currentContext, - IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) + IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, + IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery, + IFeatureService featureService, + TimeProvider timeProvider) { _deviceRepository = deviceRepository; _organizationUserRepository = organizationUserRepository; @@ -34,14 +47,27 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand _pushRegistrationService = pushRegistrationService; _currentContext = currentContext; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; + _getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery; + _featureService = featureService; + _timeProvider = timeProvider; + } + + public async Task RemoveUserAsync(Guid organizationId, Guid userId) + { + var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId); + ValidateRemoveUser(organizationId, organizationUser); + + await RepositoryRemoveUserAsync(organizationUser, deletingUserId: null, eventSystemUser: null); + + await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Removed); } public async Task RemoveUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId) { var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - ValidateDeleteUser(organizationId, organizationUser); + ValidateRemoveUser(organizationId, organizationUser); - await RepositoryDeleteUserAsync(organizationUser, deletingUserId); + await RepositoryRemoveUserAsync(organizationUser, deletingUserId, eventSystemUser: null); await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Removed); } @@ -49,108 +75,79 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand public async Task RemoveUserAsync(Guid organizationId, Guid organizationUserId, EventSystemUser eventSystemUser) { var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - ValidateDeleteUser(organizationId, organizationUser); + ValidateRemoveUser(organizationId, organizationUser); - await RepositoryDeleteUserAsync(organizationUser, null); + await RepositoryRemoveUserAsync(organizationUser, deletingUserId: null, eventSystemUser); await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Removed, eventSystemUser); } - public async Task RemoveUserAsync(Guid organizationId, Guid userId) + public async Task> RemoveUsersAsync( + Guid organizationId, IEnumerable organizationUserIds, Guid? deletingUserId) { - var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId); - ValidateDeleteUser(organizationId, organizationUser); + var result = await RemoveUsersInternalAsync(organizationId, organizationUserIds, deletingUserId, eventSystemUser: null); - await RepositoryDeleteUserAsync(organizationUser, null); + var removedUsers = result.Where(r => string.IsNullOrEmpty(r.ErrorMessage)).Select(r => r.OrganizationUser).ToList(); + if (removedUsers.Any()) + { + DateTime? eventDate = _timeProvider.GetUtcNow().UtcDateTime; + await _eventService.LogOrganizationUserEventsAsync( + removedUsers.Select(ou => (ou, EventType.OrganizationUser_Removed, eventDate))); + } - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Removed); + return result.Select(r => (r.OrganizationUser.Id, r.ErrorMessage)); } - public async Task>> RemoveUsersAsync(Guid organizationId, - IEnumerable organizationUsersId, - Guid? deletingUserId) + public async Task> RemoveUsersAsync( + Guid organizationId, IEnumerable organizationUserIds, EventSystemUser eventSystemUser) { - var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId); - var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId) - .ToList(); + var result = await RemoveUsersInternalAsync(organizationId, organizationUserIds, deletingUserId: null, eventSystemUser); - if (!filteredUsers.Any()) + var removedUsers = result.Where(r => string.IsNullOrEmpty(r.ErrorMessage)).Select(r => r.OrganizationUser).ToList(); + if (removedUsers.Any()) { - throw new BadRequestException("Users invalid."); + DateTime? eventDate = _timeProvider.GetUtcNow().UtcDateTime; + await _eventService.LogOrganizationUserEventsAsync( + removedUsers.Select(ou => (ou, EventType.OrganizationUser_Removed, eventSystemUser, eventDate))); } - if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUsersId)) - { - throw new BadRequestException("Organization must have at least one confirmed owner."); - } - - var deletingUserIsOwner = false; - if (deletingUserId.HasValue) - { - deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId); - } - - var result = new List>(); - var deletedUserIds = new List(); - foreach (var orgUser in filteredUsers) - { - try - { - if (deletingUserId.HasValue && orgUser.UserId == deletingUserId) - { - throw new BadRequestException("You cannot remove yourself."); - } - - if (orgUser.Type == OrganizationUserType.Owner && deletingUserId.HasValue && !deletingUserIsOwner) - { - throw new BadRequestException("Only owners can delete other owners."); - } - - await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Removed); - - if (orgUser.UserId.HasValue) - { - await DeleteAndPushUserRegistrationAsync(organizationId, orgUser.UserId.Value); - } - result.Add(Tuple.Create(orgUser, "")); - deletedUserIds.Add(orgUser.Id); - } - catch (BadRequestException e) - { - result.Add(Tuple.Create(orgUser, e.Message)); - } - - await _organizationUserRepository.DeleteManyAsync(deletedUserIds); - } - - return result; + return result.Select(r => (r.OrganizationUser.Id, r.ErrorMessage)); } - private void ValidateDeleteUser(Guid organizationId, OrganizationUser orgUser) + private void ValidateRemoveUser(Guid organizationId, OrganizationUser orgUser) { if (orgUser == null || orgUser.OrganizationId != organizationId) { - throw new NotFoundException("User not found."); + throw new NotFoundException(UserNotFoundErrorMessage); } } - private async Task RepositoryDeleteUserAsync(OrganizationUser orgUser, Guid? deletingUserId) + private async Task RepositoryRemoveUserAsync(OrganizationUser orgUser, Guid? deletingUserId, EventSystemUser? eventSystemUser) { if (deletingUserId.HasValue && orgUser.UserId == deletingUserId.Value) { - throw new BadRequestException("You cannot remove yourself."); + throw new BadRequestException(RemoveYourselfErrorMessage); } if (orgUser.Type == OrganizationUserType.Owner) { if (deletingUserId.HasValue && !await _currentContext.OrganizationOwner(orgUser.OrganizationId)) { - throw new BadRequestException("Only owners can delete other owners."); + throw new BadRequestException(RemoveOwnerByNonOwnerErrorMessage); } if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(orgUser.OrganizationId, new[] { orgUser.Id }, includeProvider: true)) { - throw new BadRequestException("Organization must have at least one confirmed owner."); + throw new BadRequestException(RemoveLastConfirmedOwnerErrorMessage); + } + } + + if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null) + { + var managementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, new[] { orgUser.Id }); + if (managementStatus.TryGetValue(orgUser.Id, out var isManaged) && isManaged) + { + throw new BadRequestException(RemoveClaimedAccountErrorMessage); } } @@ -177,4 +174,70 @@ public class RemoveOrganizationUserCommand : IRemoveOrganizationUserCommand organizationId.ToString()); await _pushNotificationService.PushSyncOrgKeysAsync(userId); } + + private async Task> RemoveUsersInternalAsync( + Guid organizationId, IEnumerable organizationUsersId, Guid? deletingUserId, EventSystemUser? eventSystemUser) + { + var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId); + var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId).ToList(); + + if (!filteredUsers.Any()) + { + throw new BadRequestException(UsersInvalidErrorMessage); + } + + if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUsersId)) + { + throw new BadRequestException(RemoveLastConfirmedOwnerErrorMessage); + } + + var deletingUserIsOwner = false; + if (deletingUserId.HasValue) + { + deletingUserIsOwner = await _currentContext.OrganizationOwner(organizationId); + } + + var managementStatus = _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) && deletingUserId.HasValue && eventSystemUser == null + ? await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(organizationId, filteredUsers.Select(u => u.Id)) + : filteredUsers.ToDictionary(u => u.Id, u => false); + var result = new List<(OrganizationUser OrganizationUser, string ErrorMessage)>(); + foreach (var orgUser in filteredUsers) + { + try + { + if (deletingUserId.HasValue && orgUser.UserId == deletingUserId) + { + throw new BadRequestException(RemoveYourselfErrorMessage); + } + + if (orgUser.Type == OrganizationUserType.Owner && deletingUserId.HasValue && !deletingUserIsOwner) + { + throw new BadRequestException(RemoveOwnerByNonOwnerErrorMessage); + } + + if (managementStatus.TryGetValue(orgUser.Id, out var isManaged) && isManaged) + { + throw new BadRequestException(RemoveClaimedAccountErrorMessage); + } + + result.Add((orgUser, string.Empty)); + } + catch (BadRequestException e) + { + result.Add((orgUser, e.Message)); + } + } + + var organizationUsersToRemove = result.Where(r => string.IsNullOrEmpty(r.ErrorMessage)).Select(r => r.OrganizationUser).ToList(); + if (organizationUsersToRemove.Any()) + { + await _organizationUserRepository.DeleteManyAsync(organizationUsersToRemove.Select(ou => ou.Id)); + foreach (var orgUser in organizationUsersToRemove.Where(ou => ou.UserId.HasValue)) + { + await DeleteAndPushUserRegistrationAsync(organizationId, orgUser.UserId.Value); + } + } + + return result; + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs index 2d10ce626b..61371b756e 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RemoveOrganizationUserCommandTests.cs @@ -9,6 +9,7 @@ using Bit.Core.Services; using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -18,38 +19,93 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; public class RemoveOrganizationUserCommandTests { [Theory, BitAutoData] - public async Task RemoveUser_Success( + public async Task RemoveUser_WithDeletingUserId_Success( [OrganizationUser(type: OrganizationUserType.User)] OrganizationUser organizationUser, [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser deletingUser, SutProvider sutProvider) { - var organizationUserRepository = sutProvider.GetDependency(); - var currentContext = sutProvider.GetDependency(); - + // Arrange organizationUser.OrganizationId = deletingUser.OrganizationId; - organizationUserRepository.GetByIdAsync(organizationUser.Id).Returns(organizationUser); - organizationUserRepository.GetByIdAsync(deletingUser.Id).Returns(deletingUser); - currentContext.OrganizationOwner(deletingUser.OrganizationId).Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + sutProvider.GetDependency() + .GetByIdAsync(deletingUser.Id) + .Returns(deletingUser); + sutProvider.GetDependency() + .OrganizationOwner(deletingUser.OrganizationId) + .Returns(true); + + // Act await sutProvider.Sut.RemoveUserAsync(deletingUser.OrganizationId, organizationUser.Id, deletingUser.UserId); - await organizationUserRepository.Received(1).DeleteAsync(organizationUser); - await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Removed); + // Assert + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetUsersOrganizationManagementStatusAsync(default, default); + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(organizationUser); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Removed); } - [Theory] - [BitAutoData] - public async Task RemoveUser_NotFound_ThrowsException(SutProvider sutProvider, + [Theory, BitAutoData] + public async Task RemoveUser_WithDeletingUserId_WithAccountDeprovisioningEnabled_Success( + [OrganizationUser(type: OrganizationUserType.User)] OrganizationUser organizationUser, + [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser deletingUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.OrganizationId = deletingUser.OrganizationId; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + sutProvider.GetDependency() + .GetByIdAsync(deletingUser.Id) + .Returns(deletingUser); + sutProvider.GetDependency() + .OrganizationOwner(deletingUser.OrganizationId) + .Returns(true); + + // Act + await sutProvider.Sut.RemoveUserAsync(deletingUser.OrganizationId, organizationUser.Id, deletingUser.UserId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .GetUsersOrganizationManagementStatusAsync( + organizationUser.OrganizationId, + Arg.Is>(i => i.Contains(organizationUser.Id))); + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(organizationUser); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Removed); + } + + [Theory, BitAutoData] + public async Task RemoveUser_WithDeletingUserId_NotFound_ThrowsException( + SutProvider sutProvider, Guid organizationId, Guid organizationUserId) { - await Assert.ThrowsAsync(async () => await sutProvider.Sut.RemoveUserAsync(organizationId, organizationUserId, null)); + // Act & Assert + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.RemoveUserAsync(organizationId, organizationUserId, null)); } - [Theory] - [BitAutoData] - public async Task RemoveUser_MismatchingOrganizationId_ThrowsException( + [Theory, BitAutoData] + public async Task RemoveUser_WithDeletingUserId_MismatchingOrganizationId_ThrowsException( SutProvider sutProvider, Guid organizationId, Guid organizationUserId) { + // Arrange sutProvider.GetDependency() .GetByIdAsync(organizationUserId) .Returns(new OrganizationUser @@ -58,92 +114,231 @@ public class RemoveOrganizationUserCommandTests OrganizationId = Guid.NewGuid() }); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.RemoveUserAsync(organizationId, organizationUserId, null)); + // Act & Assert + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.RemoveUserAsync(organizationId, organizationUserId, null)); } [Theory, BitAutoData] - public async Task RemoveUser_InvalidUser_ThrowsException( - OrganizationUser organizationUser, OrganizationUser deletingUser, - SutProvider sutProvider) + public async Task RemoveUser_WithDeletingUserId_InvalidUser_ThrowsException( + OrganizationUser organizationUser, SutProvider sutProvider) { - var organizationUserRepository = sutProvider.GetDependency(); - - organizationUserRepository.GetByIdAsync(organizationUser.Id).Returns(organizationUser); + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + // Act & Assert var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RemoveUserAsync(Guid.NewGuid(), organizationUser.Id, deletingUser.UserId)); - Assert.Contains("User not found.", exception.Message); + () => sutProvider.Sut.RemoveUserAsync(Guid.NewGuid(), organizationUser.Id, null)); + Assert.Contains(RemoveOrganizationUserCommand.UserNotFoundErrorMessage, exception.Message); } [Theory, BitAutoData] - public async Task RemoveUser_RemoveYourself_ThrowsException(OrganizationUser deletingUser, SutProvider sutProvider) + public async Task RemoveUser_WithDeletingUserId_RemoveYourself_ThrowsException( + OrganizationUser deletingUser, SutProvider sutProvider) { - var organizationUserRepository = sutProvider.GetDependency(); - - organizationUserRepository.GetByIdAsync(deletingUser.Id).Returns(deletingUser); + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(deletingUser.Id) + .Returns(deletingUser); + // Act & Assert var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RemoveUserAsync(deletingUser.OrganizationId, deletingUser.Id, deletingUser.UserId)); - Assert.Contains("You cannot remove yourself.", exception.Message); + Assert.Contains(RemoveOrganizationUserCommand.RemoveYourselfErrorMessage, exception.Message); } [Theory, BitAutoData] - public async Task RemoveUser_NonOwnerRemoveOwner_ThrowsException( + public async Task RemoveUser_WithDeletingUserId_NonOwnerRemoveOwner_ThrowsException( [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser organizationUser, [OrganizationUser(type: OrganizationUserType.Admin)] OrganizationUser deletingUser, SutProvider sutProvider) { - var organizationUserRepository = sutProvider.GetDependency(); - var currentContext = sutProvider.GetDependency(); - + // Arrange organizationUser.OrganizationId = deletingUser.OrganizationId; - organizationUserRepository.GetByIdAsync(organizationUser.Id).Returns(organizationUser); - currentContext.OrganizationAdmin(deletingUser.OrganizationId).Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + sutProvider.GetDependency() + .OrganizationAdmin(organizationUser.OrganizationId) + .Returns(true); + + // Act & Assert var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RemoveUserAsync(deletingUser.OrganizationId, organizationUser.Id, deletingUser.UserId)); - Assert.Contains("Only owners can delete other owners.", exception.Message); + () => sutProvider.Sut.RemoveUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUser.UserId)); + Assert.Contains(RemoveOrganizationUserCommand.RemoveOwnerByNonOwnerErrorMessage, exception.Message); } [Theory, BitAutoData] - public async Task RemoveUser_RemovingLastOwner_ThrowsException( + public async Task RemoveUser_WithDeletingUserId_RemovingLastOwner_ThrowsException( [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser organizationUser, OrganizationUser deletingUser, SutProvider sutProvider) { - var organizationUserRepository = sutProvider.GetDependency(); - var hasConfirmedOwnersExceptQuery = sutProvider.GetDependency(); - + // Arrange organizationUser.OrganizationId = deletingUser.OrganizationId; - organizationUserRepository.GetByIdAsync(organizationUser.Id).Returns(organizationUser); - hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync( - deletingUser.OrganizationId, - Arg.Is>(i => i.Contains(organizationUser.Id)), Arg.Any()) - .Returns(false); + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync( + organizationUser.OrganizationId, + Arg.Is>(i => i.Contains(organizationUser.Id)), + Arg.Any()) + .Returns(false); + sutProvider.GetDependency() + .OrganizationOwner(deletingUser.OrganizationId) + .Returns(true); + + // Act & Assert var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RemoveUserAsync(deletingUser.OrganizationId, organizationUser.Id, null)); - Assert.Contains("Organization must have at least one confirmed owner.", exception.Message); - hasConfirmedOwnersExceptQuery + () => sutProvider.Sut.RemoveUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUser.UserId)); + Assert.Contains(RemoveOrganizationUserCommand.RemoveLastConfirmedOwnerErrorMessage, exception.Message); + await sutProvider.GetDependency() .Received(1) .HasConfirmedOwnersExceptAsync( organizationUser.OrganizationId, Arg.Is>(i => i.Contains(organizationUser.Id)), true); } + [Theory, BitAutoData] + public async Task RemoveUserAsync_WithDeletingUserId_WithAccountDeprovisioningEnabled_WhenUserIsManaged_ThrowsException( + [OrganizationUser(status: OrganizationUserStatusType.Confirmed)] OrganizationUser orgUser, + Guid deletingUserId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(orgUser.Id) + .Returns(orgUser); + sutProvider.GetDependency() + .GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))) + .Returns(new Dictionary { { orgUser.Id, true } }); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RemoveUserAsync(orgUser.OrganizationId, orgUser.Id, deletingUserId)); + Assert.Contains(RemoveOrganizationUserCommand.RemoveClaimedAccountErrorMessage, exception.Message); + await sutProvider.GetDependency() + .Received(1) + .GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))); + } + [Theory, BitAutoData] public async Task RemoveUser_WithEventSystemUser_Success( [OrganizationUser(type: OrganizationUserType.User)] OrganizationUser organizationUser, + EventSystemUser eventSystemUser, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + // Act + await sutProvider.Sut.RemoveUserAsync(organizationUser.OrganizationId, organizationUser.Id, eventSystemUser); + + // Assert + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetUsersOrganizationManagementStatusAsync(default, default); + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(organizationUser); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Removed, eventSystemUser); + } + + [Theory, BitAutoData] + public async Task RemoveUser_WithEventSystemUser_WithAccountDeprovisioningEnabled_Success( + [OrganizationUser(type: OrganizationUserType.User)] OrganizationUser organizationUser, + EventSystemUser eventSystemUser, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + + // Act + await sutProvider.Sut.RemoveUserAsync(organizationUser.OrganizationId, organizationUser.Id, eventSystemUser); + + // Assert + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetUsersOrganizationManagementStatusAsync(default, default); + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(organizationUser); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Removed, eventSystemUser); + } + + [Theory] + [BitAutoData] + public async Task RemoveUser_WithEventSystemUser_NotFound_ThrowsException( + SutProvider sutProvider, + Guid organizationId, Guid organizationUserId, EventSystemUser eventSystemUser) + { + // Act & Assert + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.RemoveUserAsync(organizationId, organizationUserId, eventSystemUser)); + } + + [Theory] + [BitAutoData] + public async Task RemoveUser_WithEventSystemUser_MismatchingOrganizationId_ThrowsException( + SutProvider sutProvider, Guid organizationId, Guid organizationUserId, EventSystemUser eventSystemUser) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(organizationUserId) + .Returns(new OrganizationUser + { + Id = organizationUserId, + OrganizationId = Guid.NewGuid() + }); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.RemoveUserAsync(organizationId, organizationUserId, eventSystemUser)); + } + + [Theory, BitAutoData] + public async Task RemoveUser_WithEventSystemUser_RemovingLastOwner_ThrowsException( + [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser organizationUser, EventSystemUser eventSystemUser, SutProvider sutProvider) { - var organizationUserRepository = sutProvider.GetDependency(); + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.Id) + .Returns(organizationUser); + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync( + organizationUser.OrganizationId, + Arg.Is>(i => i.Contains(organizationUser.Id)), + Arg.Any()) + .Returns(false); - organizationUserRepository.GetByIdAsync(organizationUser.Id).Returns(organizationUser); - - await sutProvider.Sut.RemoveUserAsync(organizationUser.OrganizationId, organizationUser.Id, eventSystemUser); - - await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Removed, eventSystemUser); + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RemoveUserAsync(organizationUser.OrganizationId, organizationUser.Id, eventSystemUser)); + Assert.Contains(RemoveOrganizationUserCommand.RemoveLastConfirmedOwnerErrorMessage, exception.Message); + await sutProvider.GetDependency() + .Received(1) + .HasConfirmedOwnersExceptAsync( + organizationUser.OrganizationId, + Arg.Is>(i => i.Contains(organizationUser.Id)), true); } [Theory, BitAutoData] @@ -170,23 +365,26 @@ public class RemoveOrganizationUserCommandTests } [Theory, BitAutoData] - public async Task RemoveUser_ByUserId_NotFound_ThrowsException(SutProvider sutProvider, - Guid organizationId, Guid userId) + public async Task RemoveUser_ByUserId_NotFound_ThrowsException( + SutProvider sutProvider, Guid organizationId, Guid userId) { + // Act & Assert await Assert.ThrowsAsync(async () => await sutProvider.Sut.RemoveUserAsync(organizationId, userId)); } [Theory, BitAutoData] - public async Task RemoveUser_ByUserId_InvalidUser_ThrowsException(OrganizationUser organizationUser, - SutProvider sutProvider) + public async Task RemoveUser_ByUserId_InvalidUser_ThrowsException( + OrganizationUser organizationUser, SutProvider sutProvider) { - var organizationUserRepository = sutProvider.GetDependency(); - - organizationUserRepository.GetByOrganizationAsync(organizationUser.OrganizationId, organizationUser.UserId!.Value).Returns(organizationUser); + // Arrange + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationUser.OrganizationId, organizationUser.UserId!.Value) + .Returns(organizationUser); + // Act & Assert var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RemoveUserAsync(Guid.NewGuid(), organizationUser.UserId.Value)); - Assert.Contains("User not found.", exception.Message); + Assert.Contains(RemoveOrganizationUserCommand.UserNotFoundErrorMessage, exception.Message); } [Theory, BitAutoData] @@ -194,21 +392,22 @@ public class RemoveOrganizationUserCommandTests [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser organizationUser, SutProvider sutProvider) { - var organizationUserRepository = sutProvider.GetDependency(); - var hasConfirmedOwnersExceptQuery = sutProvider.GetDependency(); - - organizationUserRepository.GetByOrganizationAsync(organizationUser.OrganizationId, organizationUser.UserId!.Value).Returns(organizationUser); - hasConfirmedOwnersExceptQuery + // Arrange + sutProvider.GetDependency() + .GetByOrganizationAsync(organizationUser.OrganizationId, organizationUser.UserId!.Value) + .Returns(organizationUser); + sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync( organizationUser.OrganizationId, Arg.Is>(i => i.Contains(organizationUser.Id)), Arg.Any()) .Returns(false); + // Act & Assert var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RemoveUserAsync(organizationUser.OrganizationId, organizationUser.UserId.Value)); - Assert.Contains("Organization must have at least one confirmed owner.", exception.Message); - hasConfirmedOwnersExceptQuery + Assert.Contains(RemoveOrganizationUserCommand.RemoveLastConfirmedOwnerErrorMessage, exception.Message); + await sutProvider.GetDependency() .Received(1) .HasConfirmedOwnersExceptAsync( organizationUser.OrganizationId, @@ -217,93 +416,371 @@ public class RemoveOrganizationUserCommandTests } [Theory, BitAutoData] - public async Task RemoveUsers_FilterInvalid_ThrowsException(OrganizationUser organizationUser, OrganizationUser deletingUser, - SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationUsers = new[] { organizationUser }; - var organizationUserIds = organizationUsers.Select(u => u.Id); - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(organizationUsers); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RemoveUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId)); - Assert.Contains("Users invalid.", exception.Message); - } - - [Theory, BitAutoData] - public async Task RemoveUsers_RemoveYourself_ThrowsException( - OrganizationUser deletingUser, - SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationUsers = new[] { deletingUser }; - var organizationUserIds = organizationUsers.Select(u => u.Id); - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(organizationUsers); - sutProvider.GetDependency() - .HasConfirmedOwnersExceptAsync(deletingUser.OrganizationId, Arg.Any>()) - .Returns(true); - - var result = await sutProvider.Sut.RemoveUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId); - Assert.Contains("You cannot remove yourself.", result[0].Item2); - } - - [Theory, BitAutoData] - public async Task RemoveUsers_NonOwnerRemoveOwner_ThrowsException( - [OrganizationUser(type: OrganizationUserType.Admin)] OrganizationUser deletingUser, - [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser orgUser1, - [OrganizationUser(OrganizationUserStatusType.Confirmed)] OrganizationUser orgUser2, - SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - - orgUser1.OrganizationId = orgUser2.OrganizationId = deletingUser.OrganizationId; - var organizationUsers = new[] { orgUser1 }; - var organizationUserIds = organizationUsers.Select(u => u.Id); - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(organizationUsers); - sutProvider.GetDependency() - .HasConfirmedOwnersExceptAsync(deletingUser.OrganizationId, Arg.Any>()) - .Returns(true); - - var result = await sutProvider.Sut.RemoveUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId); - Assert.Contains("Only owners can delete other owners.", result[0].Item2); - } - - [Theory, BitAutoData] - public async Task RemoveUsers_LastOwner_ThrowsException( - [OrganizationUser(status: OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, - SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - - var organizationUsers = new[] { orgUser }; - var organizationUserIds = organizationUsers.Select(u => u.Id); - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(organizationUsers); - organizationUserRepository.GetManyByOrganizationAsync(orgUser.OrganizationId, OrganizationUserType.Owner).Returns(organizationUsers); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RemoveUsersAsync(orgUser.OrganizationId, organizationUserIds, null)); - Assert.Contains("Organization must have at least one confirmed owner.", exception.Message); - } - - [Theory, BitAutoData] - public async Task RemoveUsers_Success( + public async Task RemoveUsers_WithDeletingUserId_Success( [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser deletingUser, - [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser orgUser1, OrganizationUser orgUser2, - SutProvider sutProvider) + [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser orgUser1, OrganizationUser orgUser2) { - var organizationUserRepository = sutProvider.GetDependency(); - var currentContext = sutProvider.GetDependency(); - + // Arrange + var sutProvider = SutProviderFactory(); + var eventDate = sutProvider.GetDependency().GetUtcNow().UtcDateTime; orgUser1.OrganizationId = orgUser2.OrganizationId = deletingUser.OrganizationId; var organizationUsers = new[] { orgUser1, orgUser2 }; var organizationUserIds = organizationUsers.Select(u => u.Id); - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(organizationUsers); - organizationUserRepository.GetByIdAsync(deletingUser.Id).Returns(deletingUser); + + sutProvider.GetDependency() + .GetManyAsync(default) + .ReturnsForAnyArgs(organizationUsers); + sutProvider.GetDependency() + .GetByIdAsync(deletingUser.Id) + .Returns(deletingUser); sutProvider.GetDependency() .HasConfirmedOwnersExceptAsync(deletingUser.OrganizationId, Arg.Any>()) .Returns(true); - currentContext.OrganizationOwner(deletingUser.OrganizationId).Returns(true); + sutProvider.GetDependency() + .OrganizationOwner(deletingUser.OrganizationId) + .Returns(true); + sutProvider.GetDependency() + .GetUsersOrganizationManagementStatusAsync( + deletingUser.OrganizationId, + Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))) + .Returns(new Dictionary { { orgUser1.Id, false }, { orgUser2.Id, false } }); - await sutProvider.Sut.RemoveUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId); + // Act + var result = await sutProvider.Sut.RemoveUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId); + + // Assert + Assert.Equal(2, result.Count()); + Assert.All(result, r => Assert.Empty(r.ErrorMessage)); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetUsersOrganizationManagementStatusAsync(default, default); + await sutProvider.GetDependency() + .Received(1) + .DeleteManyAsync(Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventsAsync( + Arg.Is>(i => + i.First().OrganizationUser.Id == orgUser1.Id + && i.Last().OrganizationUser.Id == orgUser2.Id + && i.All(u => u.DateTime == eventDate))); + } + + [Theory, BitAutoData] + public async Task RemoveUsers_WithDeletingUserId_WithAccountDeprovisioningEnabled_Success( + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser deletingUser, + [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser orgUser1, OrganizationUser orgUser2) + { + // Arrange + var sutProvider = SutProviderFactory(); + var eventDate = sutProvider.GetDependency().GetUtcNow().UtcDateTime; + orgUser1.OrganizationId = orgUser2.OrganizationId = deletingUser.OrganizationId; + var organizationUsers = new[] { orgUser1, orgUser2 }; + var organizationUserIds = organizationUsers.Select(u => u.Id); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + sutProvider.GetDependency() + .GetManyAsync(default) + .ReturnsForAnyArgs(organizationUsers); + sutProvider.GetDependency() + .GetByIdAsync(deletingUser.Id) + .Returns(deletingUser); + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(deletingUser.OrganizationId, Arg.Any>()) + .Returns(true); + sutProvider.GetDependency() + .OrganizationOwner(deletingUser.OrganizationId) + .Returns(true); + sutProvider.GetDependency() + .GetUsersOrganizationManagementStatusAsync( + deletingUser.OrganizationId, + Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))) + .Returns(new Dictionary { { orgUser1.Id, false }, { orgUser2.Id, false } }); + + // Act + var result = await sutProvider.Sut.RemoveUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId); + + // Assert + Assert.Equal(2, result.Count()); + Assert.All(result, r => Assert.Empty(r.ErrorMessage)); + await sutProvider.GetDependency() + .Received(1) + .GetUsersOrganizationManagementStatusAsync( + deletingUser.OrganizationId, + Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); + await sutProvider.GetDependency() + .Received(1) + .DeleteManyAsync(Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventsAsync( + Arg.Is>(i => + i.First().OrganizationUser.Id == orgUser1.Id + && i.Last().OrganizationUser.Id == orgUser2.Id + && i.All(u => u.DateTime == eventDate))); + } + + [Theory, BitAutoData] + public async Task RemoveUsers_WithDeletingUserId_WithMismatchingOrganizationId_ThrowsException(OrganizationUser organizationUser, + OrganizationUser deletingUser, SutProvider sutProvider) + { + // Arrange + var organizationUsers = new[] { organizationUser }; + var organizationUserIds = organizationUsers.Select(u => u.Id); + sutProvider.GetDependency() + .GetManyAsync(default) + .ReturnsForAnyArgs(organizationUsers); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RemoveUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId)); + Assert.Contains(RemoveOrganizationUserCommand.UsersInvalidErrorMessage, exception.Message); + } + + [Theory, BitAutoData] + public async Task RemoveUsers_WithDeletingUserId_RemoveYourself_ThrowsException( + OrganizationUser deletingUser, SutProvider sutProvider) + { + // Arrange + var organizationUsers = new[] { deletingUser }; + var organizationUserIds = organizationUsers.Select(u => u.Id); + sutProvider.GetDependency() + .GetManyAsync(default) + .ReturnsForAnyArgs(organizationUsers); + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(deletingUser.OrganizationId, Arg.Any>()) + .Returns(true); + + // Act + var result = await sutProvider.Sut.RemoveUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId); + + // Assert + Assert.Contains(RemoveOrganizationUserCommand.RemoveYourselfErrorMessage, result.First().ErrorMessage); + } + + [Theory, BitAutoData] + public async Task RemoveUsers_WithDeletingUserId_NonOwnerRemoveOwner_ThrowsException( + [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Confirmed)] OrganizationUser orgUser2, + [OrganizationUser(type: OrganizationUserType.Admin)] OrganizationUser deletingUser, + SutProvider sutProvider) + { + // Arrange + orgUser1.OrganizationId = orgUser2.OrganizationId = deletingUser.OrganizationId; + var organizationUsers = new[] { orgUser1, orgUser2 }; + var organizationUserIds = organizationUsers.Select(u => u.Id); + + sutProvider.GetDependency() + .GetManyAsync(default) + .ReturnsForAnyArgs(organizationUsers); + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(deletingUser.OrganizationId, Arg.Any>()) + .Returns(true); + + // Act + var result = await sutProvider.Sut.RemoveUsersAsync(deletingUser.OrganizationId, organizationUserIds, deletingUser.UserId); + + // Assert + Assert.Contains(RemoveOrganizationUserCommand.RemoveOwnerByNonOwnerErrorMessage, result.First().ErrorMessage); + } + + [Theory, BitAutoData] + public async Task RemoveUsers_WithDeletingUserId_RemovingManagedUser_WithAccountDeprovisioningEnabled_ThrowsException( + [OrganizationUser(status: OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser, + OrganizationUser deletingUser, + SutProvider sutProvider) + { + // Arrange + orgUser.OrganizationId = deletingUser.OrganizationId; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(i => i.Contains(orgUser.Id))) + .Returns(new[] { orgUser }); + + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(orgUser.OrganizationId, Arg.Any>()) + .Returns(true); + + sutProvider.GetDependency() + .GetUsersOrganizationManagementStatusAsync(orgUser.OrganizationId, Arg.Is>(i => i.Contains(orgUser.Id))) + .Returns(new Dictionary { { orgUser.Id, true } }); + + // Act + var result = await sutProvider.Sut.RemoveUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, deletingUser.UserId); + + // Assert + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteManyAsync(default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventsAsync(Arg.Any>()); + Assert.Contains(RemoveOrganizationUserCommand.RemoveClaimedAccountErrorMessage, result.First().ErrorMessage); + } + + [Theory, BitAutoData] + public async Task RemoveUsers_WithDeletingUserId_LastOwner_ThrowsException( + [OrganizationUser(status: OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, + SutProvider sutProvider) + { + // Arrange + var organizationUsers = new[] { orgUser }; + var organizationUserIds = organizationUsers.Select(u => u.Id); + + sutProvider.GetDependency() + .GetManyAsync(default) + .ReturnsForAnyArgs(organizationUsers); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(orgUser.OrganizationId, OrganizationUserType.Owner) + .Returns(organizationUsers); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RemoveUsersAsync(orgUser.OrganizationId, organizationUserIds, null)); + Assert.Contains(RemoveOrganizationUserCommand.RemoveLastConfirmedOwnerErrorMessage, exception.Message); + } + + [Theory, BitAutoData] + public async Task RemoveUsers_WithEventSystemUser_Success( + EventSystemUser eventSystemUser, + [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser orgUser1, + OrganizationUser orgUser2) + { + // Arrange + var sutProvider = SutProviderFactory(); + var eventDate = sutProvider.GetDependency().GetUtcNow().UtcDateTime; + orgUser1.OrganizationId = orgUser2.OrganizationId; + var organizationUsers = new[] { orgUser1, orgUser2 }; + var organizationUserIds = organizationUsers.Select(u => u.Id); + + sutProvider.GetDependency() + .GetManyAsync(default) + .ReturnsForAnyArgs(organizationUsers); + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(orgUser1.OrganizationId, Arg.Any>()) + .Returns(true); + + // Act + var result = await sutProvider.Sut.RemoveUsersAsync(orgUser1.OrganizationId, organizationUserIds, eventSystemUser); + + // Assert + Assert.Equal(2, result.Count()); + Assert.All(result, r => Assert.Empty(r.ErrorMessage)); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetUsersOrganizationManagementStatusAsync(default, default); + await sutProvider.GetDependency() + .Received(1) + .DeleteManyAsync(Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventsAsync( + Arg.Is>( + i => i.First().OrganizationUser.Id == orgUser1.Id + && i.Last().OrganizationUser.Id == orgUser2.Id + && i.All(u => u.EventSystemUser == eventSystemUser + && u.DateTime == eventDate))); + } + + [Theory, BitAutoData] + public async Task RemoveUsers_WithEventSystemUser_WithAccountDeprovisioningEnabled_Success( + EventSystemUser eventSystemUser, + [OrganizationUser(type: OrganizationUserType.Owner)] OrganizationUser orgUser1, + OrganizationUser orgUser2) + { + // Arrange + var sutProvider = SutProviderFactory(); + var eventDate = sutProvider.GetDependency().GetUtcNow().UtcDateTime; + orgUser1.OrganizationId = orgUser2.OrganizationId; + var organizationUsers = new[] { orgUser1, orgUser2 }; + var organizationUserIds = organizationUsers.Select(u => u.Id); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + sutProvider.GetDependency() + .GetManyAsync(default) + .ReturnsForAnyArgs(organizationUsers); + sutProvider.GetDependency() + .HasConfirmedOwnersExceptAsync(orgUser1.OrganizationId, Arg.Any>()) + .Returns(true); + + // Act + var result = await sutProvider.Sut.RemoveUsersAsync(orgUser1.OrganizationId, organizationUserIds, eventSystemUser); + + // Assert + Assert.Equal(2, result.Count()); + Assert.All(result, r => Assert.Empty(r.ErrorMessage)); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetUsersOrganizationManagementStatusAsync(default, default); + await sutProvider.GetDependency() + .Received(1) + .DeleteManyAsync(Arg.Is>(i => i.Contains(orgUser1.Id) && i.Contains(orgUser2.Id))); + await sutProvider.GetDependency() + .Received(1) + .LogOrganizationUserEventsAsync( + Arg.Is>( + i => i.First().OrganizationUser.Id == orgUser1.Id + && i.Last().OrganizationUser.Id == orgUser2.Id + && i.All(u => u.EventSystemUser == eventSystemUser + && u.DateTime == eventDate))); + } + + [Theory, BitAutoData] + public async Task RemoveUsers_WithEventSystemUser_WithMismatchingOrganizationId_ThrowsException( + EventSystemUser eventSystemUser, + [OrganizationUser(type: OrganizationUserType.User)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + var organizationUsers = new[] { organizationUser }; + var organizationUserIds = organizationUsers.Select(u => u.Id); + sutProvider.GetDependency() + .GetManyAsync(default) + .ReturnsForAnyArgs(organizationUsers); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RemoveUsersAsync(Guid.NewGuid(), organizationUserIds, eventSystemUser)); + Assert.Contains(RemoveOrganizationUserCommand.UsersInvalidErrorMessage, exception.Message); + } + + [Theory, BitAutoData] + public async Task RemoveUsers_WithEventSystemUser_LastOwner_ThrowsException( + [OrganizationUser(status: OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, + EventSystemUser eventSystemUser, SutProvider sutProvider) + { + // Arrange + var organizationUsers = new[] { orgUser }; + var organizationUserIds = organizationUsers.Select(u => u.Id); + + sutProvider.GetDependency() + .GetManyAsync(default) + .ReturnsForAnyArgs(organizationUsers); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(orgUser.OrganizationId, OrganizationUserType.Owner) + .Returns(organizationUsers); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RemoveUsersAsync(orgUser.OrganizationId, organizationUserIds, eventSystemUser)); + Assert.Contains(RemoveOrganizationUserCommand.RemoveLastConfirmedOwnerErrorMessage, exception.Message); + } + + /// + /// Returns a new SutProvider with a FakeTimeProvider registered in the Sut. + /// + private static SutProvider SutProviderFactory() + { + return new SutProvider() + .WithFakeTimeProvider() + .Create(); } } From c8930d44f26a1003cae1f803215ea61653ab2347 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Wed, 27 Nov 2024 06:47:18 -0600 Subject: [PATCH 04/30] Swapping [] for Array.Empty (#5092) --- src/Core/Models/Commands/CommandResult.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Models/Commands/CommandResult.cs b/src/Core/Models/Commands/CommandResult.cs index 4ac2d62499..9e5d91e09c 100644 --- a/src/Core/Models/Commands/CommandResult.cs +++ b/src/Core/Models/Commands/CommandResult.cs @@ -8,5 +8,5 @@ public class CommandResult(IEnumerable errors) public bool HasErrors => ErrorMessages.Count > 0; public List ErrorMessages { get; } = errors.ToList(); - public CommandResult() : this([]) { } + public CommandResult() : this(Array.Empty()) { } } From aa364cacef9cd0e3bc3746497d0ed6593143589b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 27 Nov 2024 15:49:20 +0000 Subject: [PATCH 05/30] [PM-14876] Update admin panel copy from 'Domain Verified' to 'Claimed Account' and rename associated ViewModel properties (#5058) --- src/Admin/Models/UserEditModel.cs | 10 +++------- src/Admin/Models/UserViewModel.cs | 14 +++++++------- src/Admin/Views/Users/_ViewInformation.cshtml | 7 ++++--- test/Admin.Test/Models/UserViewModelTests.cs | 14 ++++---------- 4 files changed, 18 insertions(+), 27 deletions(-) diff --git a/src/Admin/Models/UserEditModel.cs b/src/Admin/Models/UserEditModel.cs index 2ad0b27cbd..ed2d653246 100644 --- a/src/Admin/Models/UserEditModel.cs +++ b/src/Admin/Models/UserEditModel.cs @@ -9,10 +9,7 @@ namespace Bit.Admin.Models; public class UserEditModel { - public UserEditModel() - { - - } + public UserEditModel() { } public UserEditModel( User user, @@ -21,10 +18,9 @@ public class UserEditModel BillingInfo billingInfo, BillingHistoryInfo billingHistoryInfo, GlobalSettings globalSettings, - bool? domainVerified - ) + bool? claimedAccount) { - User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, domainVerified); + User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, claimedAccount); BillingInfo = billingInfo; BillingHistoryInfo = billingHistoryInfo; diff --git a/src/Admin/Models/UserViewModel.cs b/src/Admin/Models/UserViewModel.cs index 75c089ee5f..7fddbc0f54 100644 --- a/src/Admin/Models/UserViewModel.cs +++ b/src/Admin/Models/UserViewModel.cs @@ -14,7 +14,7 @@ public class UserViewModel public bool Premium { get; } public short? MaxStorageGb { get; } public bool EmailVerified { get; } - public bool? DomainVerified { get; } + public bool? ClaimedAccount { get; } public bool TwoFactorEnabled { get; } public DateTime AccountRevisionDate { get; } public DateTime RevisionDate { get; } @@ -36,7 +36,7 @@ public class UserViewModel bool premium, short? maxStorageGb, bool emailVerified, - bool? domainVerified, + bool? claimedAccount, bool twoFactorEnabled, DateTime accountRevisionDate, DateTime revisionDate, @@ -58,7 +58,7 @@ public class UserViewModel Premium = premium; MaxStorageGb = maxStorageGb; EmailVerified = emailVerified; - DomainVerified = domainVerified; + ClaimedAccount = claimedAccount; TwoFactorEnabled = twoFactorEnabled; AccountRevisionDate = accountRevisionDate; RevisionDate = revisionDate; @@ -79,7 +79,7 @@ public class UserViewModel users.Select(user => MapViewModel(user, lookup, false)); public static UserViewModel MapViewModel(User user, - IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup, bool? domainVerified) => + IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup, bool? claimedAccount) => new( user.Id, user.Name, @@ -89,7 +89,7 @@ public class UserViewModel user.Premium, user.MaxStorageGb, user.EmailVerified, - domainVerified, + claimedAccount, IsTwoFactorEnabled(user, lookup), user.AccountRevisionDate, user.RevisionDate, @@ -106,7 +106,7 @@ public class UserViewModel public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled) => MapViewModel(user, isTwoFactorEnabled, Array.Empty(), false); - public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled, IEnumerable ciphers, bool? domainVerified) => + public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled, IEnumerable ciphers, bool? claimedAccount) => new( user.Id, user.Name, @@ -116,7 +116,7 @@ public class UserViewModel user.Premium, user.MaxStorageGb, user.EmailVerified, - domainVerified, + claimedAccount, isTwoFactorEnabled, user.AccountRevisionDate, user.RevisionDate, diff --git a/src/Admin/Views/Users/_ViewInformation.cshtml b/src/Admin/Views/Users/_ViewInformation.cshtml index 00afcc19df..ae8bcb0737 100644 --- a/src/Admin/Views/Users/_ViewInformation.cshtml +++ b/src/Admin/Views/Users/_ViewInformation.cshtml @@ -12,9 +12,10 @@
Email Verified
@(Model.EmailVerified ? "Yes" : "No")
- @if(Model.DomainVerified.HasValue){ -
Domain Verified
-
@(Model.DomainVerified.Value == true ? "Yes" : "No")
+ @if(Model.ClaimedAccount.HasValue) + { +
Claimed Account
+
@(Model.ClaimedAccount.Value ? "Yes" : "No")
}
Using 2FA
diff --git a/test/Admin.Test/Models/UserViewModelTests.cs b/test/Admin.Test/Models/UserViewModelTests.cs index fac5d5f0eb..d015b98328 100644 --- a/test/Admin.Test/Models/UserViewModelTests.cs +++ b/test/Admin.Test/Models/UserViewModelTests.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Admin.Models; +using Bit.Admin.Models; using Bit.Core.Entities; using Bit.Core.Vault.Entities; using Bit.Test.Common.AutoFixture.Attributes; @@ -116,30 +114,26 @@ public class UserViewModelTests var actual = UserViewModel.MapViewModel(user, true, Array.Empty(), verifiedDomain); - Assert.True(actual.DomainVerified); + Assert.True(actual.ClaimedAccount); } [Theory] [BitAutoData] public void MapUserViewModel_WithoutVerifiedDomain_ReturnsUserViewModel(User user) { - var verifiedDomain = false; var actual = UserViewModel.MapViewModel(user, true, Array.Empty(), verifiedDomain); - Assert.False(actual.DomainVerified); + Assert.False(actual.ClaimedAccount); } [Theory] [BitAutoData] public void MapUserViewModel_WithNullVerifiedDomain_ReturnsUserViewModel(User user) { - var actual = UserViewModel.MapViewModel(user, true, Array.Empty(), null); - Assert.Null(actual.DomainVerified); + Assert.Null(actual.ClaimedAccount); } - - } From e9297f85e9593abafb870f833dfcfad4c67e21ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 27 Nov 2024 15:55:05 +0000 Subject: [PATCH 06/30] [PM-12684] Remove deprecated feature flag for Members TwoFA query optimization (#5076) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8fe3886539..8077494794 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -129,7 +129,6 @@ public static class FeatureFlagKeys public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor"; public const string DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2"; - public const string MembersTwoFAQueryOptimization = "ac-1698-members-two-fa-query-optimization"; public const string NativeCarouselFlow = "native-carousel-flow"; public const string NativeCreateAccountFlow = "native-create-account-flow"; public const string AccountDeprovisioning = "pm-10308-account-deprovisioning"; From c703390ba2944621f541e4bd737f27a3fe0136be Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Thu, 28 Nov 2024 09:49:09 +0100 Subject: [PATCH 07/30] feat: add credential sync feature flag (#5052) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8077494794..561f5f27c8 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -155,6 +155,7 @@ public static class FeatureFlagKeys public const string SecurityTasks = "security-tasks"; public const string PM14401_ScaleMSPOnClientOrganizationUpdate = "PM-14401-scale-msp-on-client-organization-update"; public const string DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship"; + public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; public static List GetAllKeys() { From 193f8d661216042b2ce423c6d3e5e0a9a1a96eac Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Mon, 2 Dec 2024 06:42:17 -0500 Subject: [PATCH 08/30] Update version to 2024.12.0 (#5099) --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 4e252c82ed..8639ac4a0d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2024.11.0 + 2024.12.0 Bit.$(MSBuildProjectName) enable @@ -64,4 +64,4 @@ - \ No newline at end of file + From ac42b81f7c426db11a8a7fb4632a1fc295cf830c Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Mon, 2 Dec 2024 10:19:21 -0500 Subject: [PATCH 09/30] [PM-14862] Update documentation response type. (#5083) Update documentation to align with the code's response type. --- .../AdminConsole/Public/Controllers/OrganizationController.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs b/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs index 9d0b902f89..c1715f471c 100644 --- a/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs +++ b/src/Api/AdminConsole/Public/Controllers/OrganizationController.cs @@ -1,6 +1,5 @@ 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.Context; using Bit.Core.Enums; @@ -38,7 +37,7 @@ public class OrganizationController : Controller /// /// The request model. [HttpPost("import")] - [ProducesResponseType(typeof(MemberResponseModel), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(OkResult), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)] public async Task Import([FromBody] OrganizationImportRequestModel model) { From 6a77a6d8eebace51a8057b3f643135541cfbf589 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Tue, 3 Dec 2024 09:58:46 -0500 Subject: [PATCH 10/30] [PM-14552] Update error messages copy (#5059) * update error messages * fix tests --- .../Implementations/OrganizationService.cs | 32 ++++++-- .../Services/OrganizationServiceTests.cs | 78 ++++++++++++++++--- 2 files changed, 94 insertions(+), 16 deletions(-) diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 47c79aa13e..e8756fb325 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -2332,10 +2332,13 @@ public class OrganizationService : IOrganizationService PolicyType.SingleOrg, OrganizationUserStatusType.Revoked); var singleOrgPolicyApplies = singleOrgPoliciesApplyingToRevokedUsers.Any(p => p.OrganizationId == orgUser.OrganizationId); + var singleOrgCompliant = true; + var belongsToOtherOrgCompliant = true; + var twoFactorCompliant = true; + if (hasOtherOrgs && singleOrgPolicyApplies) { - throw new BadRequestException("You cannot restore this user until " + - "they leave or remove all other organizations."); + singleOrgCompliant = false; } // Enforce Single Organization Policy of other organizations user is a member of @@ -2343,8 +2346,7 @@ public class OrganizationService : IOrganizationService PolicyType.SingleOrg); if (anySingleOrgPolicies) { - throw new BadRequestException("You cannot restore this user because they are a member of " + - "another organization which forbids it"); + belongsToOtherOrgCompliant = false; } // Enforce Two Factor Authentication Policy of organization user is trying to join @@ -2354,10 +2356,28 @@ public class OrganizationService : IOrganizationService PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited); if (invitedTwoFactorPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) { - throw new BadRequestException("You cannot restore this user until they enable " + - "two-step login on their user account."); + twoFactorCompliant = false; } } + + var user = await _userRepository.GetByIdAsync(userId); + + if (!singleOrgCompliant && !twoFactorCompliant) + { + throw new BadRequestException(user.Email + " is not compliant with the single organization and two-step login polciy"); + } + else if (!singleOrgCompliant) + { + throw new BadRequestException(user.Email + " is not compliant with the single organization policy"); + } + else if (!belongsToOtherOrgCompliant) + { + throw new BadRequestException(user.Email + " belongs to an organization that doesn't allow them to join multiple organizations"); + } + else if (!twoFactorCompliant) + { + throw new BadRequestException(user.Email + " is not compliant with the two-step login policy"); + } } static OrganizationUserStatusType GetPriorActiveOrganizationUserStatusType(OrganizationUser organizationUser) diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index e09293f32d..f0b7084fe9 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -1833,11 +1833,14 @@ OrganizationUserInvite invite, SutProvider sutProvider) .AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) .Returns(true); + var user = new User(); + user.Email = "test@bitwarden.com"; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); - Assert.Contains("you cannot restore this user because they are a member of " + - "another organization which forbids it", exception.Message.ToLowerInvariant()); + Assert.Contains("test@bitwarden.com belongs to an organization that doesn't allow them to join multiple organizations", exception.Message.ToLowerInvariant()); await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); await eventService.DidNotReceiveWithAnyArgs() @@ -1865,11 +1868,14 @@ OrganizationUserInvite invite, SutProvider sutProvider) .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } }); + var user = new User(); + user.Email = "test@bitwarden.com"; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); - Assert.Contains("you cannot restore this user until they enable " + - "two-step login on their user account.", exception.Message.ToLowerInvariant()); + Assert.Contains("test@bitwarden.com is not compliant with the two-step login policy", exception.Message.ToLowerInvariant()); await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); await eventService.DidNotReceiveWithAnyArgs() @@ -1924,11 +1930,14 @@ OrganizationUserInvite invite, SutProvider sutProvider) new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked } }); + var user = new User(); + user.Email = "test@bitwarden.com"; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); - Assert.Contains("you cannot restore this user until " + - "they leave or remove all other organizations.", exception.Message.ToLowerInvariant()); + Assert.Contains("test@bitwarden.com is not compliant with the single organization policy", exception.Message.ToLowerInvariant()); await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); await eventService.DidNotReceiveWithAnyArgs() @@ -1958,11 +1967,57 @@ OrganizationUserInvite invite, SutProvider sutProvider) .AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) .Returns(true); + var user = new User(); + user.Email = "test@bitwarden.com"; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); - Assert.Contains("you cannot restore this user because they are a member of " + - "another organization which forbids it", exception.Message.ToLowerInvariant()); + Assert.Contains("test@bitwarden.com belongs to an organization that doesn't allow them to join multiple organizations", exception.Message.ToLowerInvariant()); + + await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); + await eventService.DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithSingleOrgPolicyEnabled_And_2FA_Policy_Fails( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser, + SutProvider sutProvider) + { + organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + secondOrganizationUser.UserId = organizationUser.UserId; + RestoreRevokeUser_Setup(organization, owner, organizationUser, sutProvider); + var organizationUserRepository = sutProvider.GetDependency(); + var eventService = sutProvider.GetDependency(); + + organizationUserRepository.GetManyByUserAsync(organizationUser.UserId.Value).Returns(new[] { organizationUser, secondOrganizationUser }); + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) + .Returns(new[] + { + new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked } + }); + + sutProvider.GetDependency() + .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) + .Returns(new[] + { + new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication, OrganizationUserStatus = OrganizationUserStatusType.Revoked } + }); + + var user = new User(); + user.Email = "test@bitwarden.com"; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); + + Assert.Contains("test@bitwarden.com is not compliant with the single organization and two-step login polciy", exception.Message.ToLowerInvariant()); await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); await eventService.DidNotReceiveWithAnyArgs() @@ -1986,11 +2041,14 @@ OrganizationUserInvite invite, SutProvider sutProvider) .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) .Returns(new[] { new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication } }); + var user = new User(); + user.Email = "test@bitwarden.com"; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id)); - Assert.Contains("you cannot restore this user until they enable " + - "two-step login on their user account.", exception.Message.ToLowerInvariant()); + Assert.Contains("test@bitwarden.com is not compliant with the two-step login policy", exception.Message.ToLowerInvariant()); await organizationUserRepository.DidNotReceiveWithAnyArgs().RestoreAsync(Arg.Any(), Arg.Any()); await eventService.DidNotReceiveWithAnyArgs() From 059e6816f2b24854663e9d0e7304072019cb80ce Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 3 Dec 2024 11:01:45 -0600 Subject: [PATCH 11/30] Fixing migration script. (#5093) --- util/Migrator/DbScripts/2024-11-26-00_OrgUserSetStatusBulk.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/Migrator/DbScripts/2024-11-26-00_OrgUserSetStatusBulk.sql b/util/Migrator/DbScripts/2024-11-26-00_OrgUserSetStatusBulk.sql index 95151f90de..5c51f9da40 100644 --- a/util/Migrator/DbScripts/2024-11-26-00_OrgUserSetStatusBulk.sql +++ b/util/Migrator/DbScripts/2024-11-26-00_OrgUserSetStatusBulk.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[OrganizationUser_SetStatusForUsersById] +CREATE OR ALTER PROCEDURE[dbo].[OrganizationUser_SetStatusForUsersById] @OrganizationUserIds AS NVARCHAR(MAX), @Status SMALLINT AS From b580d7c02210e9d18094f6f713ada52ce387198a Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:06:38 -0500 Subject: [PATCH 12/30] Automatically forwarding ports 1080 and 1433 in VS Code/Cursor (#5104) --- .devcontainer/internal_dev/devcontainer.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.devcontainer/internal_dev/devcontainer.json b/.devcontainer/internal_dev/devcontainer.json index ee9ab7a96d..78a79180ee 100644 --- a/.devcontainer/internal_dev/devcontainer.json +++ b/.devcontainer/internal_dev/devcontainer.json @@ -21,10 +21,15 @@ } }, "postCreateCommand": "bash .devcontainer/internal_dev/postCreateCommand.sh", + "forwardPorts": [1080, 1433], "portsAttributes": { "1080": { "label": "Mail Catcher", "onAutoForward": "notify" + }, + "1433": { + "label": "SQL Server", + "onAutoForward": "notify" } } } From c9aa61b0cf7382630553782d0301c0f9987fffe5 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Tue, 3 Dec 2024 15:38:34 -0500 Subject: [PATCH 13/30] Updated dev container to give the option of installing the Stripe CLI (#5105) --- .devcontainer/internal_dev/postCreateCommand.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.devcontainer/internal_dev/postCreateCommand.sh b/.devcontainer/internal_dev/postCreateCommand.sh index b013be1cec..668b776447 100755 --- a/.devcontainer/internal_dev/postCreateCommand.sh +++ b/.devcontainer/internal_dev/postCreateCommand.sh @@ -70,6 +70,22 @@ Press to continue." sleep 5 # wait for DB container to start dotnet run --project ./util/MsSqlMigratorUtility "$SQL_CONNECTION_STRING" fi + read -r -p "Would you like to install the Stripe CLI? [y/N] " stripe_response + if [[ "$stripe_response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then + install_stripe_cli + fi +} + +# Install Stripe CLI +install_stripe_cli() { + echo "Installing Stripe CLI..." + # Add Stripe CLI GPG key so that apt can verify the packages authenticity. + # If Stripe ever changes the key, we'll need to update this. Visit https://docs.stripe.com/stripe-cli?install-method=apt if so + curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg >/dev/null + # Add Stripe CLI repository to apt sources + echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list >/dev/null + sudo apt update + sudo apt install -y stripe } # main From 44b687922d7756e4771e0572a19af596b447a9df Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:50:47 +1000 Subject: [PATCH 14/30] [PM-14245] Remove policy definitions feature flag (#5095) * Remove PolicyService.SaveAsync and use command instead * Delete feature flag definition * Add public api integration tests --- .../Controllers/PoliciesController.cs | 29 +- .../Models/Request/PolicyRequestModel.cs | 25 +- .../Public/Controllers/PoliciesController.cs | 20 +- .../Request/PolicyUpdateRequestModel.cs | 27 +- .../Models/Response/PolicyResponseModel.cs | 6 +- .../VerifyOrganizationDomainCommand.cs | 21 +- .../Policies/ISavePolicyCommand.cs | 5 +- .../Implementations/SavePolicyCommand.cs | 4 +- .../AdminConsole/Services/IPolicyService.cs | 5 +- .../Services/Implementations/PolicyService.cs | 276 +------ .../Implementations/SsoConfigService.cs | 47 +- src/Core/Constants.cs | 1 - .../Controllers/PoliciesControllerTests.cs | 163 ++++ .../Controllers/PoliciesControllerTests.cs | 33 - .../VerifyOrganizationDomainCommandTests.cs | 28 +- .../Services/PolicyServiceTests.cs | 701 ------------------ .../Auth/Services/SsoConfigServiceTests.cs | 25 +- 17 files changed, 290 insertions(+), 1126 deletions(-) create mode 100644 test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs delete 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 ee48cdd5d4..1167d7a86c 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -6,8 +6,8 @@ using Bit.Core; 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.Repositories; -using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; using Bit.Core.Enums; @@ -28,7 +28,6 @@ namespace Bit.Api.AdminConsole.Controllers; public class PoliciesController : Controller { private readonly IPolicyRepository _policyRepository; - private readonly IPolicyService _policyService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IUserService _userService; private readonly ICurrentContext _currentContext; @@ -37,10 +36,10 @@ public class PoliciesController : Controller private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IFeatureService _featureService; private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; + private readonly ISavePolicyCommand _savePolicyCommand; public PoliciesController( IPolicyRepository policyRepository, - IPolicyService policyService, IOrganizationUserRepository organizationUserRepository, IUserService userService, ICurrentContext currentContext, @@ -48,10 +47,10 @@ public class PoliciesController : Controller IDataProtectionProvider dataProtectionProvider, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IFeatureService featureService, - IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery) + IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, + ISavePolicyCommand savePolicyCommand) { _policyRepository = policyRepository; - _policyService = policyService; _organizationUserRepository = organizationUserRepository; _userService = userService; _currentContext = currentContext; @@ -62,6 +61,7 @@ public class PoliciesController : Controller _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _featureService = featureService; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; + _savePolicyCommand = savePolicyCommand; } [HttpGet("{type}")] @@ -178,25 +178,20 @@ public class PoliciesController : Controller } [HttpPut("{type}")] - public async Task Put(string orgId, int type, [FromBody] PolicyRequestModel model) + public async Task Put(Guid orgId, PolicyType type, [FromBody] PolicyRequestModel model) { - var orgIdGuid = new Guid(orgId); - if (!await _currentContext.ManagePolicies(orgIdGuid)) + if (!await _currentContext.ManagePolicies(orgId)) { throw new NotFoundException(); } - var policy = await _policyRepository.GetByOrganizationIdTypeAsync(new Guid(orgId), (PolicyType)type); - if (policy == null) + + if (type != model.Type) { - policy = model.ToPolicy(orgIdGuid); - } - else - { - policy = model.ToPolicy(policy); + throw new BadRequestException("Mismatched policy type"); } - var userId = _userService.GetProperUserId(User); - await _policyService.SaveAsync(policy, userId); + var policyUpdate = await model.ToPolicyUpdateAsync(orgId, _currentContext); + var policy = await _savePolicyCommand.SaveAsync(policyUpdate); return new PolicyResponseModel(policy); } } diff --git a/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs b/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs index db191194d7..a243f46b2e 100644 --- a/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs @@ -1,7 +1,9 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; -using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Context; namespace Bit.Api.AdminConsole.Models.Request; @@ -13,19 +15,12 @@ public class PolicyRequestModel public bool? Enabled { get; set; } public Dictionary Data { get; set; } - public Policy ToPolicy(Guid orgId) + public async Task ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext) => new() { - return ToPolicy(new Policy - { - Type = Type.Value, - OrganizationId = orgId - }); - } - - public Policy ToPolicy(Policy existingPolicy) - { - existingPolicy.Enabled = Enabled.GetValueOrDefault(); - existingPolicy.Data = Data != null ? JsonSerializer.Serialize(Data) : null; - return existingPolicy; - } + 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)) + }; } diff --git a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs index f2e7c35d24..a22c05ed62 100644 --- a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs @@ -3,6 +3,7 @@ using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Context; @@ -18,15 +19,18 @@ public class PoliciesController : Controller private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; private readonly ICurrentContext _currentContext; + private readonly ISavePolicyCommand _savePolicyCommand; public PoliciesController( IPolicyRepository policyRepository, IPolicyService policyService, - ICurrentContext currentContext) + ICurrentContext currentContext, + ISavePolicyCommand savePolicyCommand) { _policyRepository = policyRepository; _policyService = policyService; _currentContext = currentContext; + _savePolicyCommand = savePolicyCommand; } /// @@ -80,17 +84,9 @@ public class PoliciesController : Controller [ProducesResponseType((int)HttpStatusCode.NotFound)] public async Task Put(PolicyType type, [FromBody] PolicyUpdateRequestModel model) { - var policy = await _policyRepository.GetByOrganizationIdTypeAsync( - _currentContext.OrganizationId.Value, type); - if (policy == null) - { - policy = model.ToPolicy(_currentContext.OrganizationId.Value, type); - } - else - { - policy = model.ToPolicy(policy); - } - await _policyService.SaveAsync(policy, null); + var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type); + var 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 f859686b81..eb56690462 100644 --- a/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs @@ -1,26 +1,19 @@ using System.Text.Json; -using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Enums; namespace Bit.Api.AdminConsole.Public.Models.Request; public class PolicyUpdateRequestModel : PolicyBaseModel { - public Policy ToPolicy(Guid orgId, PolicyType type) + public PolicyUpdate ToPolicyUpdate(Guid organizationId, PolicyType type) => new() { - return ToPolicy(new Policy - { - OrganizationId = orgId, - Enabled = Enabled.GetValueOrDefault(), - Data = Data != null ? JsonSerializer.Serialize(Data) : null, - Type = type - }); - } - - public virtual Policy ToPolicy(Policy existingPolicy) - { - existingPolicy.Enabled = Enabled.GetValueOrDefault(); - existingPolicy.Data = Data != null ? JsonSerializer.Serialize(Data) : null; - return existingPolicy; - } + Type = type, + OrganizationId = organizationId, + Data = Data != null ? JsonSerializer.Serialize(Data) : null, + Enabled = Enabled.GetValueOrDefault(), + PerformedBy = new SystemUser(EventSystemUser.PublicApi) + }; } diff --git a/src/Api/AdminConsole/Public/Models/Response/PolicyResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/PolicyResponseModel.cs index 27da5cc561..8da7d93cf1 100644 --- a/src/Api/AdminConsole/Public/Models/Response/PolicyResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/PolicyResponseModel.cs @@ -1,8 +1,9 @@ using System.ComponentModel.DataAnnotations; -using System.Text.Json; using Bit.Api.Models.Public.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Newtonsoft.Json; +using JsonSerializer = System.Text.Json.JsonSerializer; namespace Bit.Api.AdminConsole.Public.Models.Response; @@ -11,6 +12,9 @@ namespace Bit.Api.AdminConsole.Public.Models.Response; /// public class PolicyResponseModel : PolicyBaseModel, IResponseModel { + [JsonConstructor] + public PolicyResponseModel() { } + public PolicyResponseModel(Policy policy) { if (policy == null) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index dd6add669f..375c6326ef 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -1,8 +1,8 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; -using Bit.Core.AdminConsole.Services; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -19,9 +19,9 @@ public class VerifyOrganizationDomainCommand( IDnsResolverService dnsResolverService, IEventService eventService, IGlobalSettings globalSettings, - IPolicyService policyService, IFeatureService featureService, ICurrentContext currentContext, + ISavePolicyCommand savePolicyCommand, ILogger logger) : IVerifyOrganizationDomainCommand { @@ -125,10 +125,15 @@ public class VerifyOrganizationDomainCommand( { if (featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) { - await policyService.SaveAsync( - new Policy { OrganizationId = organizationId, Type = PolicyType.SingleOrg, Enabled = true }, - savingUserId: actingUser is StandardUser standardUser ? standardUser.UserId : null, - eventSystemUser: actingUser is SystemUser systemUser ? systemUser.SystemUserType : null); + var policyUpdate = new PolicyUpdate + { + OrganizationId = organizationId, + Type = PolicyType.SingleOrg, + Enabled = true, + PerformedBy = actingUser + }; + + await savePolicyCommand.SaveAsync(policyUpdate); } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs index 5bfdfc6aa7..6ca842686e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs @@ -1,8 +1,9 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; public interface ISavePolicyCommand { - Task SaveAsync(PolicyUpdate policy); + Task SaveAsync(PolicyUpdate policy); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs index f193aeabd1..cf332e689a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs @@ -42,7 +42,7 @@ public class SavePolicyCommand : ISavePolicyCommand _policyValidators = policyValidatorsDict; } - public async Task SaveAsync(PolicyUpdate policyUpdate) + public async Task SaveAsync(PolicyUpdate policyUpdate) { var org = await _applicationCacheService.GetOrganizationAbilityAsync(policyUpdate.OrganizationId); if (org == null) @@ -74,6 +74,8 @@ public class SavePolicyCommand : ISavePolicyCommand await _policyRepository.UpsertAsync(policy); await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated); + + return policy; } private async Task RunValidatorAsync(IPolicyValidator validator, PolicyUpdate policyUpdate) diff --git a/src/Core/AdminConsole/Services/IPolicyService.cs b/src/Core/AdminConsole/Services/IPolicyService.cs index 715c3a34d9..4f9a25f904 100644 --- a/src/Core/AdminConsole/Services/IPolicyService.cs +++ b/src/Core/AdminConsole/Services/IPolicyService.cs @@ -1,5 +1,4 @@ -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.Entities; using Bit.Core.Enums; @@ -9,8 +8,6 @@ namespace Bit.Core.AdminConsole.Services; public interface IPolicyService { - Task SaveAsync(Policy policy, Guid? savingUserId, EventSystemUser? eventSystemUser = null); - /// /// Get the combined master password policy options for the specified user. /// diff --git a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs index 69ec27fd8a..c3eb2272d0 100644 --- a/src/Core/AdminConsole/Services/Implementations/PolicyService.cs +++ b/src/Core/AdminConsole/Services/Implementations/PolicyService.cs @@ -1,19 +1,8 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Auth.Enums; -using Bit.Core.Auth.Repositories; -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; -using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; @@ -24,107 +13,20 @@ namespace Bit.Core.AdminConsole.Services.Implementations; public class PolicyService : IPolicyService { private readonly IApplicationCacheService _applicationCacheService; - private readonly IEventService _eventService; - private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IPolicyRepository _policyRepository; - private readonly ISsoConfigRepository _ssoConfigRepository; - private readonly IMailService _mailService; private readonly GlobalSettings _globalSettings; - private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; - private readonly IFeatureService _featureService; - private readonly ISavePolicyCommand _savePolicyCommand; - private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; - private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; - private readonly ICurrentContext _currentContext; public PolicyService( IApplicationCacheService applicationCacheService, - IEventService eventService, - IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IPolicyRepository policyRepository, - ISsoConfigRepository ssoConfigRepository, - IMailService mailService, - GlobalSettings globalSettings, - ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IFeatureService featureService, - ISavePolicyCommand savePolicyCommand, - IRemoveOrganizationUserCommand removeOrganizationUserCommand, - IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, - ICurrentContext currentContext) + GlobalSettings globalSettings) { _applicationCacheService = applicationCacheService; - _eventService = eventService; - _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _policyRepository = policyRepository; - _ssoConfigRepository = ssoConfigRepository; - _mailService = mailService; _globalSettings = globalSettings; - _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; - _featureService = featureService; - _savePolicyCommand = savePolicyCommand; - _removeOrganizationUserCommand = removeOrganizationUserCommand; - _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; - _currentContext = currentContext; - } - - public async Task SaveAsync(Policy policy, Guid? savingUserId, EventSystemUser? eventSystemUser = null) - { - if (_featureService.IsEnabled(FeatureFlagKeys.Pm13322AddPolicyDefinitions)) - { - // Transitional mapping - this will be moved to callers once the feature flag is removed - // TODO make sure to populate with SystemUser if not an actual user - var policyUpdate = new PolicyUpdate - { - OrganizationId = policy.OrganizationId, - Type = policy.Type, - Enabled = policy.Enabled, - Data = policy.Data, - PerformedBy = savingUserId.HasValue - ? new StandardUser(savingUserId.Value, await _currentContext.OrganizationOwner(policy.OrganizationId)) - : new SystemUser(eventSystemUser ?? EventSystemUser.Unknown) - }; - - await _savePolicyCommand.SaveAsync(policyUpdate); - return; - } - - var org = await _organizationRepository.GetByIdAsync(policy.OrganizationId); - if (org == null) - { - throw new BadRequestException("Organization not found"); - } - - if (!org.UsePolicies) - { - throw new BadRequestException("This organization cannot use policies."); - } - - // FIXME: This method will throw a bunch of errors based on if the - // policy that is being applied requires some other policy that is - // not enabled. It may be advisable to refactor this into a domain - // object and get this kind of stuff out of the service. - await HandleDependentPoliciesAsync(policy, org); - - var now = DateTime.UtcNow; - if (policy.Id == default(Guid)) - { - policy.CreationDate = now; - } - - policy.RevisionDate = now; - - // We can exit early for disable operations, because they are - // simpler. - if (!policy.Enabled) - { - await SetPolicyConfiguration(policy); - return; - } - - await EnablePolicyAsync(policy, org, savingUserId); } public async Task GetMasterPasswordPolicyForUserAsync(User user) @@ -190,178 +92,4 @@ public class PolicyService : IPolicyService return new[] { OrganizationUserType.Owner, OrganizationUserType.Admin }; } - - private async Task DependsOnSingleOrgAsync(Organization org) - { - var singleOrg = await _policyRepository.GetByOrganizationIdTypeAsync(org.Id, PolicyType.SingleOrg); - if (singleOrg?.Enabled != true) - { - throw new BadRequestException("Single Organization policy not enabled."); - } - } - - private async Task RequiredBySsoAsync(Organization org) - { - var requireSso = await _policyRepository.GetByOrganizationIdTypeAsync(org.Id, PolicyType.RequireSso); - if (requireSso?.Enabled == true) - { - throw new BadRequestException("Single Sign-On Authentication policy is enabled."); - } - } - - private async Task RequiredByKeyConnectorAsync(Organization org) - { - var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(org.Id); - if (ssoConfig?.GetData()?.MemberDecryptionType == MemberDecryptionType.KeyConnector) - { - throw new BadRequestException("Key Connector is enabled."); - } - } - - private async Task RequiredByAccountRecoveryAsync(Organization org) - { - var requireSso = await _policyRepository.GetByOrganizationIdTypeAsync(org.Id, PolicyType.ResetPassword); - if (requireSso?.Enabled == true) - { - throw new BadRequestException("Account recovery policy is enabled."); - } - } - - private async Task RequiredByVaultTimeoutAsync(Organization org) - { - var vaultTimeout = await _policyRepository.GetByOrganizationIdTypeAsync(org.Id, PolicyType.MaximumVaultTimeout); - if (vaultTimeout?.Enabled == true) - { - throw new BadRequestException("Maximum Vault Timeout policy is enabled."); - } - } - - private async Task RequiredBySsoTrustedDeviceEncryptionAsync(Organization org) - { - var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(org.Id); - if (ssoConfig?.GetData()?.MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption) - { - throw new BadRequestException("Trusted device encryption is on and requires this policy."); - } - } - - private async Task HandleDependentPoliciesAsync(Policy policy, Organization org) - { - switch (policy.Type) - { - case PolicyType.SingleOrg: - if (!policy.Enabled) - { - await HasVerifiedDomainsAsync(org); - await RequiredBySsoAsync(org); - await RequiredByVaultTimeoutAsync(org); - await RequiredByKeyConnectorAsync(org); - await RequiredByAccountRecoveryAsync(org); - } - break; - - case PolicyType.RequireSso: - if (policy.Enabled) - { - await DependsOnSingleOrgAsync(org); - } - else - { - await RequiredByKeyConnectorAsync(org); - await RequiredBySsoTrustedDeviceEncryptionAsync(org); - } - break; - - case PolicyType.ResetPassword: - if (!policy.Enabled || policy.GetDataModel()?.AutoEnrollEnabled == false) - { - await RequiredBySsoTrustedDeviceEncryptionAsync(org); - } - - if (policy.Enabled) - { - await DependsOnSingleOrgAsync(org); - } - break; - - case PolicyType.MaximumVaultTimeout: - if (policy.Enabled) - { - await DependsOnSingleOrgAsync(org); - } - break; - } - } - - private async Task HasVerifiedDomainsAsync(Organization org) - { - if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - && await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(org.Id)) - { - throw new BadRequestException("The Single organization policy is required for organizations that have enabled domain verification."); - } - } - - private async Task SetPolicyConfiguration(Policy policy) - { - await _policyRepository.UpsertAsync(policy); - await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated); - } - - private async Task EnablePolicyAsync(Policy policy, Organization org, Guid? savingUserId) - { - var currentPolicy = await _policyRepository.GetByIdAsync(policy.Id); - if (!currentPolicy?.Enabled ?? true) - { - var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(policy.OrganizationId); - var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); - var removableOrgUsers = orgUsers.Where(ou => - ou.Status != OrganizationUserStatusType.Invited && ou.Status != OrganizationUserStatusType.Revoked && - ou.Type != OrganizationUserType.Owner && ou.Type != OrganizationUserType.Admin && - ou.UserId != savingUserId); - switch (policy.Type) - { - case PolicyType.TwoFactorAuthentication: - // Reorder by HasMasterPassword to prioritize checking users without a master if they have 2FA enabled - foreach (var orgUser in removableOrgUsers.OrderBy(ou => ou.HasMasterPassword)) - { - var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == orgUser.Id).twoFactorIsEnabled; - if (!userTwoFactorEnabled) - { - if (!orgUser.HasMasterPassword) - { - throw new BadRequestException( - "Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page."); - } - - await _removeOrganizationUserCommand.RemoveUserAsync(policy.OrganizationId, orgUser.Id, - savingUserId); - await _mailService.SendOrganizationUserRemovedForPolicyTwoStepEmailAsync( - org.DisplayName(), orgUser.Email); - } - } - break; - case PolicyType.SingleOrg: - var userOrgs = await _organizationUserRepository.GetManyByManyUsersAsync( - removableOrgUsers.Select(ou => ou.UserId.Value)); - foreach (var orgUser in removableOrgUsers) - { - if (userOrgs.Any(ou => ou.UserId == orgUser.UserId - && ou.OrganizationId != org.Id - && ou.Status != OrganizationUserStatusType.Invited)) - { - await _removeOrganizationUserCommand.RemoveUserAsync(policy.OrganizationId, orgUser.Id, - savingUserId); - await _mailService.SendOrganizationUserRemovedForPolicySingleOrgEmailAsync( - org.DisplayName(), orgUser.Email); - } - } - break; - default: - break; - } - } - - await SetPolicyConfiguration(policy); - } } diff --git a/src/Core/Auth/Services/Implementations/SsoConfigService.cs b/src/Core/Auth/Services/Implementations/SsoConfigService.cs index 532f000394..bf7e2d56fe 100644 --- a/src/Core/Auth/Services/Implementations/SsoConfigService.cs +++ b/src/Core/Auth/Services/Implementations/SsoConfigService.cs @@ -1,8 +1,9 @@ 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.Repositories; -using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; @@ -17,25 +18,25 @@ public class SsoConfigService : ISsoConfigService { private readonly ISsoConfigRepository _ssoConfigRepository; private readonly IPolicyRepository _policyRepository; - private readonly IPolicyService _policyService; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IEventService _eventService; + private readonly ISavePolicyCommand _savePolicyCommand; public SsoConfigService( ISsoConfigRepository ssoConfigRepository, IPolicyRepository policyRepository, - IPolicyService policyService, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, - IEventService eventService) + IEventService eventService, + ISavePolicyCommand savePolicyCommand) { _ssoConfigRepository = ssoConfigRepository; _policyRepository = policyRepository; - _policyService = policyService; _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _eventService = eventService; + _savePolicyCommand = savePolicyCommand; } public async Task SaveAsync(SsoConfig config, Organization organization) @@ -63,25 +64,29 @@ 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) { - var singleOrgPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.SingleOrg) ?? - new Policy { OrganizationId = config.OrganizationId, Type = PolicyType.SingleOrg }; - singleOrgPolicy.Enabled = true; + await _savePolicyCommand.SaveAsync(new() + { + OrganizationId = config.OrganizationId, + Type = PolicyType.SingleOrg, + Enabled = true + }); - await _policyService.SaveAsync(singleOrgPolicy, null); + var resetPasswordPolicy = new PolicyUpdate + { + OrganizationId = config.OrganizationId, + Type = PolicyType.ResetPassword, + Enabled = true, + }; + resetPasswordPolicy.SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = true }); + await _savePolicyCommand.SaveAsync(resetPasswordPolicy); - var resetPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.ResetPassword) ?? - new Policy { OrganizationId = config.OrganizationId, Type = PolicyType.ResetPassword, }; - - resetPolicy.Enabled = true; - resetPolicy.SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = true }); - await _policyService.SaveAsync(resetPolicy, null); - - var ssoRequiredPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.RequireSso) ?? - new Policy { OrganizationId = config.OrganizationId, Type = PolicyType.RequireSso, }; - - ssoRequiredPolicy.Enabled = true; - await _policyService.SaveAsync(ssoRequiredPolicy, null); + await _savePolicyCommand.SaveAsync(new() + { + OrganizationId = config.OrganizationId, + Type = PolicyType.RequireSso, + Enabled = true + }); } await LogEventsAsync(config, oldConfig); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 561f5f27c8..6efdedfeed 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -144,7 +144,6 @@ public static class FeatureFlagKeys public const string AccessIntelligence = "pm-13227-access-intelligence"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; public const string PM12275_MultiOrganizationEnterprises = "pm-12275-multi-organization-enterprises"; - public const string Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions"; public const string LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split"; public const string GeneratorToolsModernization = "generator-tools-modernization"; public const string NewDeviceVerification = "new-device-verification"; diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs new file mode 100644 index 0000000000..f034426f98 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs @@ -0,0 +1,163 @@ +using System.Net; +using System.Text.Json; +using Bit.Api.AdminConsole.Public.Models.Request; +using Bit.Api.AdminConsole.Public.Models.Response; +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.Test.Common.Helpers; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Public.Controllers; + +public class PoliciesControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + // These will get set in `InitializeAsync` which is ran before all tests + private Organization _organization = null!; + private string _ownerEmail = null!; + + public PoliciesControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + // Create the owner account + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + // Create the organization + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually2023, + ownerEmail: _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + // Authorize with the organization api key + await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Post_NewPolicy() + { + var policyType = PolicyType.MasterPassword; + var request = new PolicyUpdateRequestModel + { + Enabled = true, + Data = new Dictionary + { + { "minComplexity", 15}, + { "requireLower", true} + } + }; + + var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request)); + + // Assert against the response + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + + Assert.True(result.Enabled); + Assert.Equal(policyType, result.Type); + Assert.IsType(result.Id); + Assert.NotEqual(default, result.Id); + Assert.NotNull(result.Data); + Assert.Equal(15, ((JsonElement)result.Data["minComplexity"]).GetInt32()); + Assert.True(((JsonElement)result.Data["requireLower"]).GetBoolean()); + + // Assert against the database values + 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.IsType(policy.Id); + Assert.NotEqual(default, policy.Id); + Assert.Equal(_organization.Id, policy.OrganizationId); + + Assert.NotNull(policy.Data); + var data = policy.GetDataModel(); + var expectedData = new MasterPasswordPolicyData { MinComplexity = 15, RequireLower = true }; + AssertHelper.AssertPropertyEqual(expectedData, data); + } + + [Fact] + public async Task Post_UpdatePolicy() + { + var policyType = PolicyType.MasterPassword; + var existingPolicy = new Policy + { + OrganizationId = _organization.Id, + Enabled = true, + Type = policyType + }; + existingPolicy.SetDataModel(new MasterPasswordPolicyData + { + EnforceOnLogin = true, + MinLength = 22, + RequireSpecial = true + }); + + var policyRepository = _factory.GetService(); + await policyRepository.UpsertAsync(existingPolicy); + + // The Id isn't set until it's created in the database, get it back out to get the id + var createdPolicy = await policyRepository.GetByOrganizationIdTypeAsync(_organization.Id, policyType); + var expectedId = createdPolicy!.Id; + + var request = new PolicyUpdateRequestModel + { + Enabled = false, + Data = new Dictionary + { + { "minLength", 15}, + { "requireUpper", true} + } + }; + + var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request)); + + // Assert against the response + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + + Assert.False(result.Enabled); + Assert.Equal(policyType, result.Type); + Assert.Equal(expectedId, result.Id); + Assert.NotNull(result.Data); + Assert.Equal(15, ((JsonElement)result.Data["minLength"]).GetInt32()); + Assert.True(((JsonElement)result.Data["requireUpper"]).GetBoolean()); + + // Assert against the database values + var policy = await policyRepository.GetByOrganizationIdTypeAsync(_organization.Id, policyType); + Assert.NotNull(policy); + + Assert.False(policy.Enabled); + Assert.Equal(policyType, policy.Type); + Assert.Equal(expectedId, policy.Id); + Assert.Equal(_organization.Id, policy.OrganizationId); + + Assert.NotNull(policy.Data); + var data = policy.GetDataModel(); + Assert.Equal(15, data.MinLength); + Assert.Equal(true, data.RequireUpper); + } +} diff --git a/test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs b/test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs deleted file mode 100644 index 71d04cae33..0000000000 --- a/test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Bit.Api.AdminConsole.Public.Controllers; -using Bit.Api.AdminConsole.Public.Models.Request; -using Bit.Api.AdminConsole.Public.Models.Response; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Context; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Mvc; -using NSubstitute; -using Xunit; - -namespace Bit.Api.Test.AdminConsole.Public.Controllers; - -[ControllerCustomize(typeof(PoliciesController))] -[SutProviderCustomize] -public class PoliciesControllerTests -{ - [Theory] - [BitAutoData] - [BitAutoData(PolicyType.SendOptions)] - public async Task Put_NewPolicy_AppliesCorrectType(PolicyType type, Organization organization, PolicyUpdateRequestModel model, SutProvider sutProvider) - { - sutProvider.GetDependency().OrganizationId.Returns(organization.Id); - sutProvider.GetDependency().GetByOrganizationIdTypeAsync(organization.Id, type).Returns((Policy)null); - - var response = await sutProvider.Sut.Put(type, model) as JsonResult; - var responseValue = response.Value as PolicyResponseModel; - - Assert.Equal(type, responseValue.Type); - } -} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs index 8dbe533131..700df88d54 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs @@ -1,7 +1,8 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; -using Bit.Core.AdminConsole.Services; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -182,9 +183,13 @@ public class VerifyOrganizationDomainCommandTests _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .SaveAsync(Arg.Is(x => x.Type == PolicyType.SingleOrg && x.OrganizationId == domain.OrganizationId && x.Enabled), userId); + .SaveAsync(Arg.Is(x => x.Type == PolicyType.SingleOrg && + x.OrganizationId == domain.OrganizationId && + x.Enabled && + x.PerformedBy is StandardUser && + x.PerformedBy.UserId == userId)); } [Theory, BitAutoData] @@ -208,9 +213,9 @@ public class VerifyOrganizationDomainCommandTests _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .SaveAsync(Arg.Any(), null); + .SaveAsync(Arg.Any()); } [Theory, BitAutoData] @@ -234,10 +239,9 @@ public class VerifyOrganizationDomainCommandTests _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .SaveAsync(Arg.Any(), null); - + .SaveAsync(Arg.Any()); } [Theory, BitAutoData] @@ -261,8 +265,8 @@ public class VerifyOrganizationDomainCommandTests _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .SaveAsync(Arg.Any(), null); + .SaveAsync(Arg.Any()); } } diff --git a/test/Core.Test/AdminConsole/Services/PolicyServiceTests.cs b/test/Core.Test/AdminConsole/Services/PolicyServiceTests.cs index 68f36e37ce..62ab584c4b 100644 --- a/test/Core.Test/AdminConsole/Services/PolicyServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/PolicyServiceTests.cs @@ -1,25 +1,13 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services.Implementations; -using Bit.Core.Auth.Entities; -using Bit.Core.Auth.Enums; -using Bit.Core.Auth.Models.Data; -using Bit.Core.Auth.Repositories; -using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Enums; -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; using Xunit; -using AdminConsoleFixtures = Bit.Core.Test.AdminConsole.AutoFixture; using GlobalSettings = Bit.Core.Settings.GlobalSettings; namespace Bit.Core.Test.AdminConsole.Services; @@ -27,667 +15,6 @@ namespace Bit.Core.Test.AdminConsole.Services; [SutProviderCustomize] public class PolicyServiceTests { - [Theory, BitAutoData] - public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest( - [AdminConsoleFixtures.Policy(PolicyType.DisableSend)] Policy policy, SutProvider sutProvider) - { - SetupOrg(sutProvider, policy.OrganizationId, null); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, - Guid.NewGuid())); - - Assert.Contains("Organization not found", badRequestException.Message, StringComparison.OrdinalIgnoreCase); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpsertAsync(default); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogPolicyEventAsync(default, default, default); - } - - [Theory, BitAutoData] - public async Task SaveAsync_OrganizationCannotUsePolicies_ThrowsBadRequest( - [AdminConsoleFixtures.Policy(PolicyType.DisableSend)] Policy policy, SutProvider sutProvider) - { - var orgId = Guid.NewGuid(); - - SetupOrg(sutProvider, policy.OrganizationId, new Organization - { - UsePolicies = false, - }); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, - Guid.NewGuid())); - - Assert.Contains("cannot use policies", badRequestException.Message, StringComparison.OrdinalIgnoreCase); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpsertAsync(default); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogPolicyEventAsync(default, default, default); - } - - [Theory, BitAutoData] - public async Task SaveAsync_SingleOrg_RequireSsoEnabled_ThrowsBadRequest( - [AdminConsoleFixtures.Policy(PolicyType.SingleOrg)] Policy policy, SutProvider sutProvider) - { - policy.Enabled = false; - - SetupOrg(sutProvider, policy.OrganizationId, new Organization - { - Id = policy.OrganizationId, - UsePolicies = true, - }); - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policy.OrganizationId, PolicyType.RequireSso) - .Returns(Task.FromResult(new Policy { Enabled = true })); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, - Guid.NewGuid())); - - Assert.Contains("Single Sign-On Authentication policy is enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpsertAsync(default); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogPolicyEventAsync(default, default, default); - } - - [Theory, BitAutoData] - public async Task SaveAsync_SingleOrg_VaultTimeoutEnabled_ThrowsBadRequest([AdminConsoleFixtures.Policy(PolicyType.SingleOrg)] Policy policy, SutProvider sutProvider) - { - policy.Enabled = false; - - SetupOrg(sutProvider, policy.OrganizationId, new Organization - { - Id = policy.OrganizationId, - UsePolicies = true, - }); - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policy.OrganizationId, PolicyType.MaximumVaultTimeout) - .Returns(new Policy { Enabled = true }); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, - Guid.NewGuid())); - - Assert.Contains("Maximum Vault Timeout policy is enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpsertAsync(default); - } - - [Theory] - [BitAutoData(PolicyType.SingleOrg)] - [BitAutoData(PolicyType.RequireSso)] - public async Task SaveAsync_PolicyRequiredByKeyConnector_DisablePolicy_ThrowsBadRequest( - PolicyType policyType, - Policy policy, - SutProvider sutProvider) - { - policy.Enabled = false; - policy.Type = policyType; - - SetupOrg(sutProvider, policy.OrganizationId, new Organization - { - Id = policy.OrganizationId, - UsePolicies = true, - }); - - var ssoConfig = new SsoConfig { Enabled = true }; - var data = new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector }; - ssoConfig.SetData(data); - - sutProvider.GetDependency() - .GetByOrganizationIdAsync(policy.OrganizationId) - .Returns(ssoConfig); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, - Guid.NewGuid())); - - Assert.Contains("Key Connector is enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpsertAsync(default); - } - - [Theory, BitAutoData] - public async Task SaveAsync_RequireSsoPolicy_NotEnabled_ThrowsBadRequestAsync( - [AdminConsoleFixtures.Policy(PolicyType.RequireSso)] Policy policy, SutProvider sutProvider) - { - policy.Enabled = true; - - SetupOrg(sutProvider, policy.OrganizationId, new Organization - { - Id = policy.OrganizationId, - UsePolicies = true, - }); - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policy.OrganizationId, PolicyType.SingleOrg) - .Returns(Task.FromResult(new Policy { Enabled = false })); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, - Guid.NewGuid())); - - Assert.Contains("Single Organization policy not enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpsertAsync(default); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogPolicyEventAsync(default, default, default); - } - - [Theory, BitAutoData] - public async Task SaveAsync_NewPolicy_Created( - [AdminConsoleFixtures.Policy(PolicyType.ResetPassword)] Policy policy, SutProvider sutProvider) - { - policy.Id = default; - policy.Data = null; - - SetupOrg(sutProvider, policy.OrganizationId, new Organization - { - Id = policy.OrganizationId, - UsePolicies = true, - }); - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policy.OrganizationId, PolicyType.SingleOrg) - .Returns(Task.FromResult(new Policy { Enabled = true })); - - var utcNow = DateTime.UtcNow; - - await sutProvider.Sut.SaveAsync(policy, Guid.NewGuid()); - - await sutProvider.GetDependency().Received() - .LogPolicyEventAsync(policy, EventType.Policy_Updated); - - await sutProvider.GetDependency().Received() - .UpsertAsync(policy); - - Assert.True(policy.CreationDate - utcNow < TimeSpan.FromSeconds(1)); - Assert.True(policy.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); - } - - [Theory, BitAutoData] - public async Task SaveAsync_VaultTimeoutPolicy_NotEnabled_ThrowsBadRequestAsync( - [AdminConsoleFixtures.Policy(PolicyType.MaximumVaultTimeout)] Policy policy, SutProvider sutProvider) - { - policy.Enabled = true; - - SetupOrg(sutProvider, policy.OrganizationId, new Organization - { - Id = policy.OrganizationId, - UsePolicies = true, - }); - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policy.OrganizationId, PolicyType.SingleOrg) - .Returns(Task.FromResult(new Policy { Enabled = false })); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, - Guid.NewGuid())); - - Assert.Contains("Single Organization policy not enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpsertAsync(default); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogPolicyEventAsync(default, default, default); - } - - [Theory, BitAutoData] - public async Task SaveAsync_ExistingPolicy_UpdateTwoFactor( - Organization organization, - [AdminConsoleFixtures.Policy(PolicyType.TwoFactorAuthentication)] Policy policy, - SutProvider sutProvider) - { - // If the policy that this is updating isn't enabled then do some work now that the current one is enabled - - organization.UsePolicies = true; - policy.OrganizationId = organization.Id; - - SetupOrg(sutProvider, organization.Id, organization); - - sutProvider.GetDependency() - .GetByIdAsync(policy.Id) - .Returns(new Policy - { - Id = policy.Id, - Type = PolicyType.TwoFactorAuthentication, - Enabled = false - }); - - var orgUserDetailUserInvited = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Invited, - Type = OrganizationUserType.User, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "user1@test.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = false - }; - var orgUserDetailUserAcceptedWith2FA = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Accepted, - Type = OrganizationUserType.User, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "user2@test.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = true - }; - var orgUserDetailUserAcceptedWithout2FA = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Accepted, - Type = OrganizationUserType.User, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "user3@test.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = true - }; - var orgUserDetailAdmin = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.Admin, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "admin@test.com", - Name = "ADMIN", - UserId = Guid.NewGuid(), - HasMasterPassword = false - }; - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policy.OrganizationId) - .Returns(new List - { - orgUserDetailUserInvited, - orgUserDetailUserAcceptedWith2FA, - orgUserDetailUserAcceptedWithout2FA, - orgUserDetailAdmin - }); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Any>()) - .Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>() - { - (orgUserDetailUserInvited, false), - (orgUserDetailUserAcceptedWith2FA, true), - (orgUserDetailUserAcceptedWithout2FA, false), - (orgUserDetailAdmin, false), - }); - - var removeOrganizationUserCommand = sutProvider.GetDependency(); - - var utcNow = DateTime.UtcNow; - - var savingUserId = Guid.NewGuid(); - - await sutProvider.Sut.SaveAsync(policy, savingUserId); - - await removeOrganizationUserCommand.Received() - .RemoveUserAsync(policy.OrganizationId, orgUserDetailUserAcceptedWithout2FA.Id, savingUserId); - await sutProvider.GetDependency().Received() - .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserAcceptedWithout2FA.Email); - - await removeOrganizationUserCommand.DidNotReceive() - .RemoveUserAsync(policy.OrganizationId, orgUserDetailUserInvited.Id, savingUserId); - await sutProvider.GetDependency().DidNotReceive() - .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserInvited.Email); - await removeOrganizationUserCommand.DidNotReceive() - .RemoveUserAsync(policy.OrganizationId, orgUserDetailUserAcceptedWith2FA.Id, savingUserId); - await sutProvider.GetDependency().DidNotReceive() - .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailUserAcceptedWith2FA.Email); - await removeOrganizationUserCommand.DidNotReceive() - .RemoveUserAsync(policy.OrganizationId, orgUserDetailAdmin.Id, savingUserId); - await sutProvider.GetDependency().DidNotReceive() - .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(organization.DisplayName(), orgUserDetailAdmin.Email); - - await sutProvider.GetDependency().Received() - .LogPolicyEventAsync(policy, EventType.Policy_Updated); - - await sutProvider.GetDependency().Received() - .UpsertAsync(policy); - - Assert.True(policy.CreationDate - utcNow < TimeSpan.FromSeconds(1)); - Assert.True(policy.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); - } - - [Theory, BitAutoData] - public async Task SaveAsync_EnableTwoFactor_WithoutMasterPasswordOr2FA_ThrowsBadRequest( - Organization organization, - [AdminConsoleFixtures.Policy(PolicyType.TwoFactorAuthentication)] Policy policy, - SutProvider sutProvider) - { - organization.UsePolicies = true; - policy.OrganizationId = organization.Id; - - SetupOrg(sutProvider, organization.Id, organization); - - var orgUserDetailUserWith2FAAndMP = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.User, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "user1@test.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = true - }; - var orgUserDetailUserWith2FANoMP = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.User, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "user2@test.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = false - }; - var orgUserDetailUserWithout2FA = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.User, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "user3@test.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = false - }; - var orgUserDetailAdmin = new OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.Admin, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "admin@test.com", - Name = "ADMIN", - UserId = Guid.NewGuid(), - HasMasterPassword = false - }; - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policy.OrganizationId) - .Returns(new List - { - orgUserDetailUserWith2FAAndMP, - orgUserDetailUserWith2FANoMP, - orgUserDetailUserWithout2FA, - orgUserDetailAdmin - }); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Is>(ids => - ids.Contains(orgUserDetailUserWith2FANoMP.UserId.Value) - && ids.Contains(orgUserDetailUserWithout2FA.UserId.Value) - && ids.Contains(orgUserDetailAdmin.UserId.Value))) - .Returns(new List<(Guid userId, bool hasTwoFactor)>() - { - (orgUserDetailUserWith2FANoMP.UserId.Value, true), - (orgUserDetailUserWithout2FA.UserId.Value, false), - (orgUserDetailAdmin.UserId.Value, false), - }); - - var removeOrganizationUserCommand = sutProvider.GetDependency(); - - var savingUserId = Guid.NewGuid(); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, savingUserId)); - - Assert.Contains("Policy could not be enabled. Non-compliant members will lose access to their accounts. Identify members without two-step login from the policies column in the members page.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); - - await removeOrganizationUserCommand.DidNotReceiveWithAnyArgs() - .RemoveUserAsync(organizationId: default, organizationUserId: default, deletingUserId: default); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .SendOrganizationUserRemovedForPolicyTwoStepEmailAsync(default, default); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .LogPolicyEventAsync(default, default); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .UpsertAsync(default); - } - - [Theory, BitAutoData] - public async Task SaveAsync_ExistingPolicy_UpdateSingleOrg( - [AdminConsoleFixtures.Policy(PolicyType.TwoFactorAuthentication)] Policy policy, SutProvider sutProvider) - { - // If the policy that this is updating isn't enabled then do some work now that the current one is enabled - - var org = new Organization - { - Id = policy.OrganizationId, - UsePolicies = true, - Name = "TEST", - }; - - SetupOrg(sutProvider, policy.OrganizationId, org); - - sutProvider.GetDependency() - .GetByIdAsync(policy.Id) - .Returns(new Policy - { - Id = policy.Id, - Type = PolicyType.SingleOrg, - Enabled = false, - }); - - var orgUserDetail = new Core.Models.Data.Organizations.OrganizationUsers.OrganizationUserUserDetails - { - Id = Guid.NewGuid(), - Status = OrganizationUserStatusType.Accepted, - Type = OrganizationUserType.User, - // Needs to be different from what is passed in as the savingUserId to Sut.SaveAsync - Email = "test@bitwarden.com", - Name = "TEST", - UserId = Guid.NewGuid(), - HasMasterPassword = true - }; - - sutProvider.GetDependency() - .GetManyDetailsByOrganizationAsync(policy.OrganizationId) - .Returns(new List - { - orgUserDetail, - }); - - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(orgUserDetail.UserId.Value))) - .Returns(new List<(Guid userId, bool hasTwoFactor)>() - { - (orgUserDetail.UserId.Value, false), - }); - - var utcNow = DateTime.UtcNow; - - var savingUserId = Guid.NewGuid(); - - await sutProvider.Sut.SaveAsync(policy, savingUserId); - - await sutProvider.GetDependency().Received() - .LogPolicyEventAsync(policy, EventType.Policy_Updated); - - await sutProvider.GetDependency().Received() - .UpsertAsync(policy); - - Assert.True(policy.CreationDate - utcNow < TimeSpan.FromSeconds(1)); - Assert.True(policy.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); - } - - [Theory] - [BitAutoData(true, false)] - [BitAutoData(false, true)] - [BitAutoData(false, false)] - public async Task SaveAsync_ResetPasswordPolicyRequiredByTrustedDeviceEncryption_DisablePolicyOrDisableAutomaticEnrollment_ThrowsBadRequest( - bool policyEnabled, - bool autoEnrollEnabled, - [AdminConsoleFixtures.Policy(PolicyType.ResetPassword)] Policy policy, - SutProvider sutProvider) - { - policy.Enabled = policyEnabled; - policy.SetDataModel(new ResetPasswordDataModel - { - AutoEnrollEnabled = autoEnrollEnabled - }); - - SetupOrg(sutProvider, policy.OrganizationId, new Organization - { - Id = policy.OrganizationId, - UsePolicies = true, - }); - - var ssoConfig = new SsoConfig { Enabled = true }; - ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption }); - - sutProvider.GetDependency() - .GetByOrganizationIdAsync(policy.OrganizationId) - .Returns(ssoConfig); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, - Guid.NewGuid())); - - Assert.Contains("Trusted device encryption is on and requires this policy.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpsertAsync(default); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogPolicyEventAsync(default, default, default); - } - - [Theory, BitAutoData] - public async Task SaveAsync_RequireSsoPolicyRequiredByTrustedDeviceEncryption_DisablePolicy_ThrowsBadRequest( - [AdminConsoleFixtures.Policy(PolicyType.RequireSso)] Policy policy, - SutProvider sutProvider) - { - policy.Enabled = false; - - SetupOrg(sutProvider, policy.OrganizationId, new Organization - { - Id = policy.OrganizationId, - UsePolicies = true, - }); - - var ssoConfig = new SsoConfig { Enabled = true }; - ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption }); - - sutProvider.GetDependency() - .GetByOrganizationIdAsync(policy.OrganizationId) - .Returns(ssoConfig); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, - Guid.NewGuid())); - - Assert.Contains("Trusted device encryption is on and requires this policy.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpsertAsync(default); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogPolicyEventAsync(default, default, default); - } - - [Theory, BitAutoData] - public async Task SaveAsync_PolicyRequiredForAccountRecovery_NotEnabled_ThrowsBadRequestAsync( - [AdminConsoleFixtures.Policy(PolicyType.ResetPassword)] Policy policy, SutProvider sutProvider) - { - policy.Enabled = true; - policy.SetDataModel(new ResetPasswordDataModel()); - - SetupOrg(sutProvider, policy.OrganizationId, new Organization - { - Id = policy.OrganizationId, - UsePolicies = true, - }); - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policy.OrganizationId, PolicyType.SingleOrg) - .Returns(Task.FromResult(new Policy { Enabled = false })); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, - Guid.NewGuid())); - - Assert.Contains("Single Organization policy not enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpsertAsync(default); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogPolicyEventAsync(default, default, default); - } - - - [Theory, BitAutoData] - public async Task SaveAsync_SingleOrg_AccountRecoveryEnabled_ThrowsBadRequest( - [AdminConsoleFixtures.Policy(PolicyType.SingleOrg)] Policy policy, SutProvider sutProvider) - { - policy.Enabled = false; - - SetupOrg(sutProvider, policy.OrganizationId, new Organization - { - Id = policy.OrganizationId, - UsePolicies = true, - }); - - sutProvider.GetDependency() - .GetByOrganizationIdTypeAsync(policy.OrganizationId, PolicyType.ResetPassword) - .Returns(new Policy { Enabled = true }); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, - Guid.NewGuid())); - - Assert.Contains("Account recovery policy is enabled.", badRequestException.Message, StringComparison.OrdinalIgnoreCase); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .UpsertAsync(default); - } - [Theory, BitAutoData] public async Task GetPoliciesApplicableToUserAsync_WithRequireSsoTypeFilter_WithDefaultOrganizationUserStatusFilter_ReturnsNoPolicies(Guid userId, SutProvider sutProvider) { @@ -816,32 +143,4 @@ public class PolicyServiceTests new() { OrganizationId = Guid.NewGuid(), PolicyType = PolicyType.DisableSend, PolicyEnabled = true, OrganizationUserType = OrganizationUserType.User, OrganizationUserStatus = OrganizationUserStatusType.Invited, IsProvider = true } }); } - - - [Theory, BitAutoData] - public async Task SaveAsync_GivenOrganizationUsingPoliciesAndHasVerifiedDomains_WhenSingleOrgPolicyIsDisabled_ThenAnErrorShouldBeThrownOrganizationHasVerifiedDomains( - [AdminConsoleFixtures.Policy(PolicyType.SingleOrg)] Policy policy, Organization org, SutProvider sutProvider) - { - org.Id = policy.OrganizationId; - org.UsePolicies = true; - - policy.Enabled = false; - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) - .Returns(true); - - sutProvider.GetDependency() - .GetByIdAsync(policy.OrganizationId) - .Returns(org); - - sutProvider.GetDependency() - .HasVerifiedDomainsAsync(org.Id) - .Returns(true); - - var badRequestException = await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveAsync(policy, null)); - - Assert.Equal("The Single organization policy is required for organizations that have enabled domain verification.", badRequestException.Message); - } } diff --git a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs index e397c838c6..7beb772b95 100644 --- a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs +++ b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs @@ -1,8 +1,9 @@ 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.Repositories; -using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; @@ -338,16 +339,26 @@ public class SsoConfigServiceTests await sutProvider.Sut.SaveAsync(ssoConfig, organization); - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .SaveAsync( - Arg.Is(t => t.Type == PolicyType.SingleOrg), - null + Arg.Is(t => t.Type == PolicyType.SingleOrg && + t.OrganizationId == organization.Id && + t.Enabled) ); - await sutProvider.GetDependency().Received(1) + await sutProvider.GetDependency().Received(1) .SaveAsync( - Arg.Is(t => t.Type == PolicyType.ResetPassword && t.GetDataModel().AutoEnrollEnabled), - null + Arg.Is(t => t.Type == PolicyType.ResetPassword && + t.GetDataModel().AutoEnrollEnabled && + t.OrganizationId == organization.Id && + t.Enabled) + ); + + await sutProvider.GetDependency().Received(1) + .SaveAsync( + Arg.Is(t => t.Type == PolicyType.RequireSso && + t.OrganizationId == organization.Id && + t.Enabled) ); await sutProvider.GetDependency().ReceivedWithAnyArgs() From 94fdfa40e8af9c9b788aafe2cf89eacc2913eeea Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Wed, 4 Dec 2024 11:45:11 +0100 Subject: [PATCH 15/30] [PM-13999] Show estimated tax for taxable countries (#5077) --- .../Billing/TaxServiceTests.cs | 151 +++ .../Controllers/AccountsBillingController.cs | 15 + .../Billing/Controllers/InvoicesController.cs | 42 + .../Controllers/ProviderBillingController.cs | 1 + .../Billing/Controllers/StripeController.cs | 14 +- .../Requests/TaxInformationRequestBody.cs | 2 + .../Billing/Extensions/CurrencyExtensions.cs | 33 + .../Extensions/ServiceCollectionExtensions.cs | 1 + .../PreviewIndividualInvoiceRequestModel.cs | 18 + .../PreviewOrganizationInvoiceRequestModel.cs | 37 + .../Requests/TaxInformationRequestModel.cs | 14 + .../Responses/PreviewInvoiceResponseModel.cs | 7 + src/Core/Billing/Models/PreviewInvoiceInfo.cs | 7 + .../Billing/Models/Sales/OrganizationSale.cs | 1 + src/Core/Billing/Models/TaxIdType.cs | 22 + src/Core/Billing/Models/TaxInformation.cs | 160 +--- src/Core/Billing/Services/ITaxService.cs | 22 + .../OrganizationBillingService.cs | 35 +- .../PremiumUserBillingService.cs | 37 +- .../Implementations/SubscriberService.cs | 51 +- src/Core/Billing/Services/TaxService.cs | 901 ++++++++++++++++++ src/Core/Billing/Utilities.cs | 1 + src/Core/Models/Business/TaxInfo.cs | 208 +--- src/Core/Services/IPaymentService.cs | 6 + src/Core/Services/IStripeAdapter.cs | 2 + .../Services/Implementations/StripeAdapter.cs | 12 + .../Implementations/StripePaymentService.cs | 389 +++++++- .../Services/SubscriberServiceTests.cs | 3 +- .../Core.Test/Models/Business/TaxInfoTests.cs | 114 --- .../Services/StripePaymentServiceTests.cs | 18 +- 30 files changed, 1793 insertions(+), 531 deletions(-) create mode 100644 bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs create mode 100644 src/Api/Billing/Controllers/InvoicesController.cs create mode 100644 src/Core/Billing/Extensions/CurrencyExtensions.cs create mode 100644 src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs create mode 100644 src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs create mode 100644 src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs create mode 100644 src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs create mode 100644 src/Core/Billing/Models/PreviewInvoiceInfo.cs create mode 100644 src/Core/Billing/Models/TaxIdType.cs create mode 100644 src/Core/Billing/Services/ITaxService.cs create mode 100644 src/Core/Billing/Services/TaxService.cs delete mode 100644 test/Core.Test/Models/Business/TaxInfoTests.cs diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs new file mode 100644 index 0000000000..3995fb9de6 --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs @@ -0,0 +1,151 @@ +using Bit.Core.Billing.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Commercial.Core.Test.Billing; + +[SutProviderCustomize] +public class TaxServiceTests +{ + [Theory] + [BitAutoData("AD", "A-123456-Z", "ad_nrt")] + [BitAutoData("AD", "A123456Z", "ad_nrt")] + [BitAutoData("AR", "20-12345678-9", "ar_cuit")] + [BitAutoData("AR", "20123456789", "ar_cuit")] + [BitAutoData("AU", "01259983598", "au_abn")] + [BitAutoData("AU", "123456789123", "au_arn")] + [BitAutoData("AT", "ATU12345678", "eu_vat")] + [BitAutoData("BH", "123456789012345", "bh_vat")] + [BitAutoData("BY", "123456789", "by_tin")] + [BitAutoData("BE", "BE0123456789", "eu_vat")] + [BitAutoData("BO", "123456789", "bo_tin")] + [BitAutoData("BR", "01.234.456/5432-10", "br_cnpj")] + [BitAutoData("BR", "01234456543210", "br_cnpj")] + [BitAutoData("BR", "123.456.789-87", "br_cpf")] + [BitAutoData("BR", "12345678987", "br_cpf")] + [BitAutoData("BG", "123456789", "bg_uic")] + [BitAutoData("BG", "BG012100705", "eu_vat")] + [BitAutoData("CA", "100728494", "ca_bn")] + [BitAutoData("CA", "123456789RT0001", "ca_gst_hst")] + [BitAutoData("CA", "PST-1234-1234", "ca_pst_bc")] + [BitAutoData("CA", "123456-7", "ca_pst_mb")] + [BitAutoData("CA", "1234567", "ca_pst_sk")] + [BitAutoData("CA", "1234567890TQ1234", "ca_qst")] + [BitAutoData("CL", "11.121.326-1", "cl_tin")] + [BitAutoData("CL", "11121326-1", "cl_tin")] + [BitAutoData("CL", "23.121.326-K", "cl_tin")] + [BitAutoData("CL", "43651326-K", "cl_tin")] + [BitAutoData("CN", "123456789012345678", "cn_tin")] + [BitAutoData("CN", "123456789012345", "cn_tin")] + [BitAutoData("CO", "123.456.789-0", "co_nit")] + [BitAutoData("CO", "1234567890", "co_nit")] + [BitAutoData("CR", "1-234-567890", "cr_tin")] + [BitAutoData("CR", "1234567890", "cr_tin")] + [BitAutoData("HR", "HR12345678912", "eu_vat")] + [BitAutoData("HR", "12345678901", "hr_oib")] + [BitAutoData("CY", "CY12345678X", "eu_vat")] + [BitAutoData("CZ", "CZ12345678", "eu_vat")] + [BitAutoData("DK", "DK12345678", "eu_vat")] + [BitAutoData("DO", "123-4567890-1", "do_rcn")] + [BitAutoData("DO", "12345678901", "do_rcn")] + [BitAutoData("EC", "1234567890001", "ec_ruc")] + [BitAutoData("EG", "123456789", "eg_tin")] + [BitAutoData("SV", "1234-567890-123-4", "sv_nit")] + [BitAutoData("SV", "12345678901234", "sv_nit")] + [BitAutoData("EE", "EE123456789", "eu_vat")] + [BitAutoData("EU", "EU123456789", "eu_oss_vat")] + [BitAutoData("FI", "FI12345678", "eu_vat")] + [BitAutoData("FR", "FR12345678901", "eu_vat")] + [BitAutoData("GE", "123456789", "ge_vat")] + [BitAutoData("DE", "1234567890", "de_stn")] + [BitAutoData("DE", "DE123456789", "eu_vat")] + [BitAutoData("GR", "EL123456789", "eu_vat")] + [BitAutoData("HK", "12345678", "hk_br")] + [BitAutoData("HU", "HU12345678", "eu_vat")] + [BitAutoData("HU", "12345678-1-23", "hu_tin")] + [BitAutoData("HU", "12345678123", "hu_tin")] + [BitAutoData("IS", "123456", "is_vat")] + [BitAutoData("IN", "12ABCDE1234F1Z5", "in_gst")] + [BitAutoData("IN", "12ABCDE3456FGZH", "in_gst")] + [BitAutoData("ID", "012.345.678.9-012.345", "id_npwp")] + [BitAutoData("ID", "0123456789012345", "id_npwp")] + [BitAutoData("IE", "IE1234567A", "eu_vat")] + [BitAutoData("IE", "IE1234567AB", "eu_vat")] + [BitAutoData("IL", "000012345", "il_vat")] + [BitAutoData("IL", "123456789", "il_vat")] + [BitAutoData("IT", "IT12345678901", "eu_vat")] + [BitAutoData("JP", "1234567890123", "jp_cn")] + [BitAutoData("JP", "12345", "jp_rn")] + [BitAutoData("KZ", "123456789012", "kz_bin")] + [BitAutoData("KE", "P000111111A", "ke_pin")] + [BitAutoData("LV", "LV12345678912", "eu_vat")] + [BitAutoData("LI", "CHE123456789", "li_uid")] + [BitAutoData("LI", "12345", "li_vat")] + [BitAutoData("LT", "LT123456789123", "eu_vat")] + [BitAutoData("LU", "LU12345678", "eu_vat")] + [BitAutoData("MY", "12345678", "my_frp")] + [BitAutoData("MY", "C 1234567890", "my_itn")] + [BitAutoData("MY", "C1234567890", "my_itn")] + [BitAutoData("MY", "A12-3456-78912345", "my_sst")] + [BitAutoData("MY", "A12345678912345", "my_sst")] + [BitAutoData("MT", "MT12345678", "eu_vat")] + [BitAutoData("MX", "ABC010203AB9", "mx_rfc")] + [BitAutoData("MD", "1003600", "md_vat")] + [BitAutoData("MA", "12345678", "ma_vat")] + [BitAutoData("NL", "NL123456789B12", "eu_vat")] + [BitAutoData("NZ", "123456789", "nz_gst")] + [BitAutoData("NG", "12345678-0001", "ng_tin")] + [BitAutoData("NO", "123456789MVA", "no_vat")] + [BitAutoData("NO", "1234567", "no_voec")] + [BitAutoData("OM", "OM1234567890", "om_vat")] + [BitAutoData("PE", "12345678901", "pe_ruc")] + [BitAutoData("PH", "123456789012", "ph_tin")] + [BitAutoData("PL", "PL1234567890", "eu_vat")] + [BitAutoData("PT", "PT123456789", "eu_vat")] + [BitAutoData("RO", "RO1234567891", "eu_vat")] + [BitAutoData("RO", "1234567890123", "ro_tin")] + [BitAutoData("RU", "1234567891", "ru_inn")] + [BitAutoData("RU", "123456789", "ru_kpp")] + [BitAutoData("SA", "123456789012345", "sa_vat")] + [BitAutoData("RS", "123456789", "rs_pib")] + [BitAutoData("SG", "M12345678X", "sg_gst")] + [BitAutoData("SG", "123456789F", "sg_uen")] + [BitAutoData("SK", "SK1234567891", "eu_vat")] + [BitAutoData("SI", "SI12345678", "eu_vat")] + [BitAutoData("SI", "12345678", "si_tin")] + [BitAutoData("ZA", "4123456789", "za_vat")] + [BitAutoData("KR", "123-45-67890", "kr_brn")] + [BitAutoData("KR", "1234567890", "kr_brn")] + [BitAutoData("ES", "A12345678", "es_cif")] + [BitAutoData("ES", "ESX1234567X", "eu_vat")] + [BitAutoData("SE", "SE123456789012", "eu_vat")] + [BitAutoData("CH", "CHE-123.456.789 HR", "ch_uid")] + [BitAutoData("CH", "CHE123456789HR", "ch_uid")] + [BitAutoData("CH", "CHE-123.456.789 MWST", "ch_vat")] + [BitAutoData("CH", "CHE123456789MWST", "ch_vat")] + [BitAutoData("TW", "12345678", "tw_vat")] + [BitAutoData("TH", "1234567890123", "th_vat")] + [BitAutoData("TR", "0123456789", "tr_tin")] + [BitAutoData("UA", "123456789", "ua_vat")] + [BitAutoData("AE", "123456789012345", "ae_trn")] + [BitAutoData("GB", "XI123456789", "eu_vat")] + [BitAutoData("GB", "GB123456789", "gb_vat")] + [BitAutoData("US", "12-3456789", "us_ein")] + [BitAutoData("UY", "123456789012", "uy_ruc")] + [BitAutoData("UZ", "123456789", "uz_tin")] + [BitAutoData("UZ", "123456789012", "uz_vat")] + [BitAutoData("VE", "A-12345678-9", "ve_rif")] + [BitAutoData("VE", "A123456789", "ve_rif")] + [BitAutoData("VN", "1234567890", "vn_tin")] + public void GetStripeTaxCode_WithValidCountryAndTaxId_ReturnsExpectedTaxIdType( + string country, + string taxId, + string expected, + SutProvider sutProvider) + { + var result = sutProvider.Sut.GetStripeTaxCode(country, taxId); + + Assert.Equal(expected, result); + } +} diff --git a/src/Api/Billing/Controllers/AccountsBillingController.cs b/src/Api/Billing/Controllers/AccountsBillingController.cs index 574ac3e65e..fcb89226e7 100644 --- a/src/Api/Billing/Controllers/AccountsBillingController.cs +++ b/src/Api/Billing/Controllers/AccountsBillingController.cs @@ -1,5 +1,6 @@ #nullable enable using Bit.Api.Billing.Models.Responses; +using Bit.Core.Billing.Models.Api.Requests.Accounts; using Bit.Core.Billing.Services; using Bit.Core.Services; using Bit.Core.Utilities; @@ -77,4 +78,18 @@ public class AccountsBillingController( return TypedResults.Ok(transactions); } + + [HttpPost("preview-invoice")] + public async Task PreviewInvoiceAsync([FromBody] PreviewIndividualInvoiceRequestBody model) + { + var user = await userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var invoice = await paymentService.PreviewInvoiceAsync(model, user.GatewayCustomerId, user.GatewaySubscriptionId); + + return TypedResults.Ok(invoice); + } } diff --git a/src/Api/Billing/Controllers/InvoicesController.cs b/src/Api/Billing/Controllers/InvoicesController.cs new file mode 100644 index 0000000000..686d9b9643 --- /dev/null +++ b/src/Api/Billing/Controllers/InvoicesController.cs @@ -0,0 +1,42 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Models.Api.Requests.Organizations; +using Bit.Core.Context; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Billing.Controllers; + +[Route("invoices")] +[Authorize("Application")] +public class InvoicesController : BaseBillingController +{ + [HttpPost("preview-organization")] + public async Task PreviewInvoiceAsync( + [FromBody] PreviewOrganizationInvoiceRequestBody model, + [FromServices] ICurrentContext currentContext, + [FromServices] IOrganizationRepository organizationRepository, + [FromServices] IPaymentService paymentService) + { + Organization organization = null; + if (model.OrganizationId != default) + { + if (!await currentContext.EditPaymentMethods(model.OrganizationId)) + { + return Error.Unauthorized(); + } + + organization = await organizationRepository.GetByIdAsync(model.OrganizationId); + if (organization == null) + { + return Error.NotFound(); + } + } + + var invoice = await paymentService.PreviewInvoiceAsync(model, organization?.GatewayCustomerId, + organization?.GatewaySubscriptionId); + + return TypedResults.Ok(invoice); + } +} diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index f7ddf0853e..c5de63c69b 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -119,6 +119,7 @@ public class ProviderBillingController( requestBody.Country, requestBody.PostalCode, requestBody.TaxId, + requestBody.TaxIdType, requestBody.Line1, requestBody.Line2, requestBody.City, diff --git a/src/Api/Billing/Controllers/StripeController.cs b/src/Api/Billing/Controllers/StripeController.cs index a4a974bb99..f5e8253bfa 100644 --- a/src/Api/Billing/Controllers/StripeController.cs +++ b/src/Api/Billing/Controllers/StripeController.cs @@ -1,4 +1,5 @@ -using Bit.Core.Services; +using Bit.Core.Billing.Services; +using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -46,4 +47,15 @@ public class StripeController( return TypedResults.Ok(setupIntent.ClientSecret); } + + [HttpGet] + [Route("~/tax/is-country-supported")] + public IResult IsCountrySupported( + [FromQuery] string country, + [FromServices] ITaxService taxService) + { + var isSupported = taxService.IsSupported(country); + + return TypedResults.Ok(isSupported); + } } diff --git a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs index c5c0fde00b..32ba2effb2 100644 --- a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs @@ -10,6 +10,7 @@ public class TaxInformationRequestBody [Required] public string PostalCode { get; set; } public string TaxId { get; set; } + public string TaxIdType { get; set; } public string Line1 { get; set; } public string Line2 { get; set; } public string City { get; set; } @@ -19,6 +20,7 @@ public class TaxInformationRequestBody Country, PostalCode, TaxId, + TaxIdType, Line1, Line2, City, diff --git a/src/Core/Billing/Extensions/CurrencyExtensions.cs b/src/Core/Billing/Extensions/CurrencyExtensions.cs new file mode 100644 index 0000000000..cde1a7bea8 --- /dev/null +++ b/src/Core/Billing/Extensions/CurrencyExtensions.cs @@ -0,0 +1,33 @@ +namespace Bit.Core.Billing.Extensions; + +public static class CurrencyExtensions +{ + /// + /// Converts a currency amount in major units to minor units. + /// + /// 123.99 USD returns 12399 in minor units. + public static long ToMinor(this decimal amount) + { + return Convert.ToInt64(amount * 100); + } + + /// + /// Converts a currency amount in minor units to major units. + /// + /// + /// 12399 in minor units returns 123.99 USD. + public static decimal? ToMajor(this long? amount) + { + return amount?.ToMajor(); + } + + /// + /// Converts a currency amount in minor units to major units. + /// + /// + /// 12399 in minor units returns 123.99 USD. + public static decimal ToMajor(this long amount) + { + return Convert.ToDecimal(amount) / 100; + } +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index abfceac736..8f2803d920 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ public static class ServiceCollectionExtensions { public static void AddBillingOperations(this IServiceCollection services) { + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs new file mode 100644 index 0000000000..6dfb9894d5 --- /dev/null +++ b/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Billing.Models.Api.Requests.Accounts; + +public class PreviewIndividualInvoiceRequestBody +{ + [Required] + public PasswordManagerRequestModel PasswordManager { get; set; } + + [Required] + public TaxInformationRequestModel TaxInformation { get; set; } +} + +public class PasswordManagerRequestModel +{ + [Range(0, int.MaxValue)] + public int AdditionalStorage { get; set; } +} diff --git a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs new file mode 100644 index 0000000000..18d9c352d7 --- /dev/null +++ b/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Models.Api.Requests.Organizations; + +public class PreviewOrganizationInvoiceRequestBody +{ + public Guid OrganizationId { get; set; } + + [Required] + public PasswordManagerRequestModel PasswordManager { get; set; } + + public SecretsManagerRequestModel SecretsManager { get; set; } + + [Required] + public TaxInformationRequestModel TaxInformation { get; set; } +} + +public class PasswordManagerRequestModel +{ + public PlanType Plan { get; set; } + + [Range(0, int.MaxValue)] + public int Seats { get; set; } + + [Range(0, int.MaxValue)] + public int AdditionalStorage { get; set; } +} + +public class SecretsManagerRequestModel +{ + [Range(0, int.MaxValue)] + public int Seats { get; set; } + + [Range(0, int.MaxValue)] + public int AdditionalMachineAccounts { get; set; } +} diff --git a/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs b/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs new file mode 100644 index 0000000000..9cb43645c6 --- /dev/null +++ b/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Billing.Models.Api.Requests; + +public class TaxInformationRequestModel +{ + [Length(2, 2), Required] + public string Country { get; set; } + + [Required] + public string PostalCode { get; set; } + + public string TaxId { get; set; } +} diff --git a/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs b/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs new file mode 100644 index 0000000000..fdde7dae1e --- /dev/null +++ b/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Models.Api.Responses; + +public record PreviewInvoiceResponseModel( + decimal EffectiveTaxRate, + decimal TaxableBaseAmount, + decimal TaxAmount, + decimal TotalAmount); diff --git a/src/Core/Billing/Models/PreviewInvoiceInfo.cs b/src/Core/Billing/Models/PreviewInvoiceInfo.cs new file mode 100644 index 0000000000..16a2019c20 --- /dev/null +++ b/src/Core/Billing/Models/PreviewInvoiceInfo.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Models; + +public record PreviewInvoiceInfo( + decimal EffectiveTaxRate, + decimal TaxableBaseAmount, + decimal TaxAmount, + decimal TotalAmount); diff --git a/src/Core/Billing/Models/Sales/OrganizationSale.cs b/src/Core/Billing/Models/Sales/OrganizationSale.cs index a19c278c68..43852bb320 100644 --- a/src/Core/Billing/Models/Sales/OrganizationSale.cs +++ b/src/Core/Billing/Models/Sales/OrganizationSale.cs @@ -65,6 +65,7 @@ public class OrganizationSale signup.TaxInfo.BillingAddressCountry, signup.TaxInfo.BillingAddressPostalCode, signup.TaxInfo.TaxIdNumber, + signup.TaxInfo.TaxIdType, signup.TaxInfo.BillingAddressLine1, signup.TaxInfo.BillingAddressLine2, signup.TaxInfo.BillingAddressCity, diff --git a/src/Core/Billing/Models/TaxIdType.cs b/src/Core/Billing/Models/TaxIdType.cs new file mode 100644 index 0000000000..3fc246d68b --- /dev/null +++ b/src/Core/Billing/Models/TaxIdType.cs @@ -0,0 +1,22 @@ +using System.Text.RegularExpressions; + +namespace Bit.Core.Billing.Models; + +public class TaxIdType +{ + /// + /// ISO-3166-2 code for the country. + /// + public string Country { get; set; } + + /// + /// The identifier in Stripe for the tax ID type. + /// + public string Code { get; set; } + + public Regex ValidationExpression { get; set; } + + public string Description { get; set; } + + public string Example { get; set; } +} diff --git a/src/Core/Billing/Models/TaxInformation.cs b/src/Core/Billing/Models/TaxInformation.cs index 5403f94690..23ed3e5faa 100644 --- a/src/Core/Billing/Models/TaxInformation.cs +++ b/src/Core/Billing/Models/TaxInformation.cs @@ -1,5 +1,4 @@ using Bit.Core.Models.Business; -using Stripe; namespace Bit.Core.Billing.Models; @@ -7,6 +6,7 @@ public record TaxInformation( string Country, string PostalCode, string TaxId, + string TaxIdType, string Line1, string Line2, string City, @@ -16,165 +16,9 @@ public record TaxInformation( taxInfo.BillingAddressCountry, taxInfo.BillingAddressPostalCode, taxInfo.TaxIdNumber, + taxInfo.TaxIdType, taxInfo.BillingAddressLine1, taxInfo.BillingAddressLine2, taxInfo.BillingAddressCity, taxInfo.BillingAddressState); - - public (AddressOptions, List) GetStripeOptions() - { - var address = new AddressOptions - { - Country = Country, - PostalCode = PostalCode, - Line1 = Line1, - Line2 = Line2, - City = City, - State = State - }; - - var customerTaxIdDataOptionsList = !string.IsNullOrEmpty(TaxId) - ? new List { new() { Type = GetTaxIdType(), Value = TaxId } } - : null; - - return (address, customerTaxIdDataOptionsList); - } - - public string GetTaxIdType() - { - if (string.IsNullOrEmpty(Country) || string.IsNullOrEmpty(TaxId)) - { - return null; - } - - switch (Country.ToUpper()) - { - case "AD": - return "ad_nrt"; - case "AE": - return "ae_trn"; - case "AR": - return "ar_cuit"; - case "AU": - return "au_abn"; - case "BO": - return "bo_tin"; - case "BR": - return "br_cnpj"; - case "CA": - // May break for those in Québec given the assumption of QST - if (State?.Contains("bec") ?? false) - { - return "ca_qst"; - } - return "ca_bn"; - case "CH": - return "ch_vat"; - case "CL": - return "cl_tin"; - case "CN": - return "cn_tin"; - case "CO": - return "co_nit"; - case "CR": - return "cr_tin"; - case "DO": - return "do_rcn"; - case "EC": - return "ec_ruc"; - case "EG": - return "eg_tin"; - case "GE": - return "ge_vat"; - case "ID": - return "id_npwp"; - case "IL": - return "il_vat"; - case "IS": - return "is_vat"; - case "KE": - return "ke_pin"; - case "AT": - case "BE": - case "BG": - case "CY": - case "CZ": - case "DE": - case "DK": - case "EE": - case "ES": - case "FI": - case "FR": - case "GB": - case "GR": - case "HR": - case "HU": - case "IE": - case "IT": - case "LT": - case "LU": - case "LV": - case "MT": - case "NL": - case "PL": - case "PT": - case "RO": - case "SE": - case "SI": - case "SK": - return "eu_vat"; - case "HK": - return "hk_br"; - case "IN": - return "in_gst"; - case "JP": - return "jp_cn"; - case "KR": - return "kr_brn"; - case "LI": - return "li_uid"; - case "MX": - return "mx_rfc"; - case "MY": - return "my_sst"; - case "NO": - return "no_vat"; - case "NZ": - return "nz_gst"; - case "PE": - return "pe_ruc"; - case "PH": - return "ph_tin"; - case "RS": - return "rs_pib"; - case "RU": - return "ru_inn"; - case "SA": - return "sa_vat"; - case "SG": - return "sg_gst"; - case "SV": - return "sv_nit"; - case "TH": - return "th_vat"; - case "TR": - return "tr_tin"; - case "TW": - return "tw_vat"; - case "UA": - return "ua_vat"; - case "US": - return "us_ein"; - case "UY": - return "uy_ruc"; - case "VE": - return "ve_rif"; - case "VN": - return "vn_tin"; - case "ZA": - return "za_vat"; - default: - return null; - } - } } diff --git a/src/Core/Billing/Services/ITaxService.cs b/src/Core/Billing/Services/ITaxService.cs new file mode 100644 index 0000000000..beee113d17 --- /dev/null +++ b/src/Core/Billing/Services/ITaxService.cs @@ -0,0 +1,22 @@ +namespace Bit.Core.Billing.Services; + +public interface ITaxService +{ + /// + /// Retrieves the Stripe tax code for a given country and tax ID. + /// + /// + /// + /// + /// Returns the Stripe tax code if the tax ID is valid for the country. + /// Returns null if the tax ID is invalid or the country is not supported. + /// + string GetStripeTaxCode(string country, string taxId); + + /// + /// Returns true or false whether charging or storing tax is supported for the given country. + /// + /// + /// + bool IsSupported(string country); +} diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index eadc589625..b186a99d93 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -28,7 +28,8 @@ public class OrganizationBillingService( IOrganizationRepository organizationRepository, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, - ISubscriberService subscriberService) : IOrganizationBillingService + ISubscriberService subscriberService, + ITaxService taxService) : IOrganizationBillingService { public async Task Finalize(OrganizationSale sale) { @@ -167,14 +168,38 @@ public class OrganizationBillingService( throw new BillingException(); } - var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions(); - - customerCreateOptions.Address = address; + customerCreateOptions.Address = new AddressOptions + { + Line1 = customerSetup.TaxInformation.Line1, + Line2 = customerSetup.TaxInformation.Line2, + City = customerSetup.TaxInformation.City, + PostalCode = customerSetup.TaxInformation.PostalCode, + State = customerSetup.TaxInformation.State, + Country = customerSetup.TaxInformation.Country, + }; customerCreateOptions.Tax = new CustomerTaxOptions { ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately }; - customerCreateOptions.TaxIdData = taxIdData; + + if (!string.IsNullOrEmpty(customerSetup.TaxInformation.TaxId)) + { + var taxIdType = taxService.GetStripeTaxCode(customerSetup.TaxInformation.Country, + customerSetup.TaxInformation.TaxId); + + if (taxIdType == null) + { + logger.LogWarning("Could not determine tax ID type for organization '{OrganizationID}' in country '{Country}' with tax ID '{TaxID}'.", + organization.Id, + customerSetup.TaxInformation.Country, + customerSetup.TaxInformation.TaxId); + } + + customerCreateOptions.TaxIdData = + [ + new() { Type = taxIdType, Value = customerSetup.TaxInformation.TaxId } + ]; + } var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource; diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 92c81dae1c..5351888ad9 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -24,7 +24,8 @@ public class PremiumUserBillingService( ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, - IUserRepository userRepository) : IPremiumUserBillingService + IUserRepository userRepository, + ITaxService taxService) : IPremiumUserBillingService { public async Task Finalize(PremiumUserSale sale) { @@ -82,13 +83,19 @@ public class PremiumUserBillingService( throw new BillingException(); } - var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions(); - var subscriberName = user.SubscriberName(); var customerCreateOptions = new CustomerCreateOptions { - Address = address, + Address = new AddressOptions + { + Line1 = customerSetup.TaxInformation.Line1, + Line2 = customerSetup.TaxInformation.Line2, + City = customerSetup.TaxInformation.City, + PostalCode = customerSetup.TaxInformation.PostalCode, + State = customerSetup.TaxInformation.State, + Country = customerSetup.TaxInformation.Country, + }, Description = user.Name, Email = user.Email, Expand = ["tax"], @@ -113,10 +120,28 @@ public class PremiumUserBillingService( Tax = new CustomerTaxOptions { ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately - }, - TaxIdData = taxIdData + } }; + if (!string.IsNullOrEmpty(customerSetup.TaxInformation.TaxId)) + { + var taxIdType = taxService.GetStripeTaxCode(customerSetup.TaxInformation.Country, + customerSetup.TaxInformation.TaxId); + + if (taxIdType == null) + { + logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", + customerSetup.TaxInformation.Country, + customerSetup.TaxInformation.TaxId); + throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError"); + } + + customerCreateOptions.TaxIdData = + [ + new() { Type = taxIdType, Value = customerSetup.TaxInformation.TaxId } + ]; + } + var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource; var braintreeCustomerId = ""; diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 9b8f64be82..6125d15419 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -23,7 +23,8 @@ public class SubscriberService( IGlobalSettings globalSettings, ILogger logger, ISetupIntentCache setupIntentCache, - IStripeAdapter stripeAdapter) : ISubscriberService + IStripeAdapter stripeAdapter, + ITaxService taxService) : ISubscriberService { public async Task CancelSubscription( ISubscriber subscriber, @@ -618,16 +619,47 @@ public class SubscriberService( await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); } - var taxIdType = taxInformation.GetTaxIdType(); - - if (!string.IsNullOrWhiteSpace(taxInformation.TaxId) && - !string.IsNullOrWhiteSpace(taxIdType)) + if (string.IsNullOrWhiteSpace(taxInformation.TaxId)) { - await stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions + return; + } + + var taxIdType = taxInformation.TaxIdType; + if (string.IsNullOrWhiteSpace(taxIdType)) + { + taxIdType = taxService.GetStripeTaxCode(taxInformation.Country, + taxInformation.TaxId); + + if (taxIdType == null) { - Type = taxIdType, - Value = taxInformation.TaxId, - }); + logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", + taxInformation.Country, + taxInformation.TaxId); + throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError"); + } + } + + try + { + await stripeAdapter.TaxIdCreateAsync(customer.Id, + new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId }); + } + catch (StripeException e) + { + switch (e.StripeError.Code) + { + case StripeConstants.ErrorCodes.TaxIdInvalid: + logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", + taxInformation.TaxId, + taxInformation.Country); + throw new Exceptions.BadRequestException("billingInvalidTaxIdError"); + default: + logger.LogError(e, "Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.", + taxInformation.TaxId, + taxInformation.Country, + customer.Id); + throw new Exceptions.BadRequestException("billingTaxIdCreationError"); + } } } @@ -770,6 +802,7 @@ public class SubscriberService( customer.Address.Country, customer.Address.PostalCode, customer.TaxIds?.FirstOrDefault()?.Value, + customer.TaxIds?.FirstOrDefault()?.Type, customer.Address.Line1, customer.Address.Line2, customer.Address.City, diff --git a/src/Core/Billing/Services/TaxService.cs b/src/Core/Billing/Services/TaxService.cs new file mode 100644 index 0000000000..3066be92d1 --- /dev/null +++ b/src/Core/Billing/Services/TaxService.cs @@ -0,0 +1,901 @@ +using System.Text.RegularExpressions; +using Bit.Core.Billing.Models; + +namespace Bit.Core.Billing.Services; + +public class TaxService : ITaxService +{ + /// + /// Retrieves a list of supported tax ID types for customers. + /// + /// Compiled list from Stripe + private static readonly IEnumerable _taxIdTypes = + [ + new() + { + Country = "AD", + Code = "ad_nrt", + Description = "Andorran NRT number", + Example = "A-123456-Z", + ValidationExpression = new Regex("^([A-Z]{1})-?([0-9]{6})-?([A-Z]{1})$") + }, + new() + { + Country = "AR", + Code = "ar_cuit", + Description = "Argentinian tax ID number", + Example = "12-34567890-1", + ValidationExpression = new Regex("^([0-9]{2})-?([0-9]{8})-?([0-9]{1})$") + }, + new() + { + Country = "AU", + Code = "au_abn", + Description = "Australian Business Number (AU ABN)", + Example = "123456789012", + ValidationExpression = new Regex("^[0-9]{11}$") + }, + new() + { + Country = "AU", + Code = "au_arn", + Description = "Australian Taxation Office Reference Number", + Example = "123456789123", + ValidationExpression = new Regex("^[0-9]{12}$") + }, + new() + { + Country = "AT", + Code = "eu_vat", + Description = "European VAT number (Austria)", + Example = "ATU12345678", + ValidationExpression = new Regex("^ATU[0-9]{8}$") + }, + new() + { + Country = "BH", + Code = "bh_vat", + Description = "Bahraini VAT Number", + Example = "123456789012345", + ValidationExpression = new Regex("^[0-9]{15}$") + }, + new() + { + Country = "BY", + Code = "by_tin", + Description = "Belarus TIN Number", + Example = "123456789", + ValidationExpression = new Regex("^[0-9]{9}$") + }, + new() + { + Country = "BE", + Code = "eu_vat", + Description = "European VAT number (Belgium)", + Example = "BE0123456789", + ValidationExpression = new Regex("^BE[0-9]{10}$") + }, + new() + { + Country = "BO", + Code = "bo_tin", + Description = "Bolivian tax ID", + Example = "123456789", + ValidationExpression = new Regex("^[0-9]{9}$") + }, + new() + { + Country = "BR", + Code = "br_cnpj", + Description = "Brazilian CNPJ number", + Example = "01.234.456/5432-10", + ValidationExpression = new Regex("^[0-9]{2}.?[0-9]{3}.?[0-9]{3}/?[0-9]{4}-?[0-9]{2}$") + }, + new() + { + Country = "BR", + Code = "br_cpf", + Description = "Brazilian CPF number", + Example = "123.456.789-87", + ValidationExpression = new Regex("^[0-9]{3}.?[0-9]{3}.?[0-9]{3}-?[0-9]{2}$") + }, + new() + { + Country = "BG", + Code = "bg_uic", + Description = "Bulgaria Unified Identification Code", + Example = "123456789", + ValidationExpression = new Regex("^[0-9]{9}$") + }, + new() + { + Country = "BG", + Code = "eu_vat", + Description = "European VAT number (Bulgaria)", + Example = "BG0123456789", + ValidationExpression = new Regex("^BG[0-9]{9,10}$") + }, + new() + { + Country = "CA", + Code = "ca_bn", + Description = "Canadian BN", + Example = "123456789", + ValidationExpression = new Regex("^[0-9]{9}$") + }, + new() + { + Country = "CA", + Code = "ca_gst_hst", + Description = "Canadian GST/HST number", + Example = "123456789RT0002", + ValidationExpression = new Regex("^[0-9]{9}RT[0-9]{4}$") + }, + new() + { + Country = "CA", + Code = "ca_pst_bc", + Description = "Canadian PST number (British Columbia)", + Example = "PST-1234-5678", + ValidationExpression = new Regex("^PST-[0-9]{4}-[0-9]{4}$") + }, + new() + { + Country = "CA", + Code = "ca_pst_mb", + Description = "Canadian PST number (Manitoba)", + Example = "123456-7", + ValidationExpression = new Regex("^[0-9]{6}-[0-9]{1}$") + }, + new() + { + Country = "CA", + Code = "ca_pst_sk", + Description = "Canadian PST number (Saskatchewan)", + Example = "1234567", + ValidationExpression = new Regex("^[0-9]{7}$") + }, + new() + { + Country = "CA", + Code = "ca_qst", + Description = "Canadian QST number (Québec)", + Example = "1234567890TQ1234", + ValidationExpression = new Regex("^[0-9]{10}TQ[0-9]{4}$") + }, + new() + { + Country = "CL", + Code = "cl_tin", + Description = "Chilean TIN", + Example = "12.345.678-K", + ValidationExpression = new Regex("^[0-9]{2}.?[0-9]{3}.?[0-9]{3}-?[0-9A-Z]{1}$") + }, + new() + { + Country = "CN", + Code = "cn_tin", + Description = "Chinese tax ID", + Example = "123456789012345678", + ValidationExpression = new Regex("^[0-9]{15,18}$") + }, + new() + { + Country = "CO", + Code = "co_nit", + Description = "Colombian NIT number", + Example = "123.456.789-0", + ValidationExpression = new Regex("^[0-9]{3}.?[0-9]{3}.?[0-9]{3}-?[0-9]{1}$") + }, + new() + { + Country = "CR", + Code = "cr_tin", + Description = "Costa Rican tax ID", + Example = "1-234-567890", + ValidationExpression = new Regex("^[0-9]{1}-?[0-9]{3}-?[0-9]{6}$") + }, + new() + { + Country = "HR", + Code = "eu_vat", + Description = "European VAT number (Croatia)", + Example = "HR12345678912", + ValidationExpression = new Regex("^HR[0-9]{11}$") + }, + new() + { + Country = "HR", + Code = "hr_oib", + Description = "Croatian Personal Identification Number", + Example = "12345678901", + ValidationExpression = new Regex("^[0-9]{11}$") + }, + new() + { + Country = "CY", + Code = "eu_vat", + Description = "European VAT number (Cyprus)", + Example = "CY12345678X", + ValidationExpression = new Regex("^CY[0-9]{8}[A-Z]{1}$") + }, + new() + { + Country = "CZ", + Code = "eu_vat", + Description = "European VAT number (Czech Republic)", + Example = "CZ12345678", + ValidationExpression = new Regex("^CZ[0-9]{8,10}$") + }, + new() + { + Country = "DK", + Code = "eu_vat", + Description = "European VAT number (Denmark)", + Example = "DK12345678", + ValidationExpression = new Regex("^DK[0-9]{8}$") + }, + new() + { + Country = "DO", + Code = "do_rcn", + Description = "Dominican RCN number", + Example = "123-4567890-1", + ValidationExpression = new Regex("^[0-9]{3}-?[0-9]{7}-?[0-9]{1}$") + }, + new() + { + Country = "EC", + Code = "ec_ruc", + Description = "Ecuadorian RUC number", + Example = "1234567890001", + ValidationExpression = new Regex("^[0-9]{13}$") + }, + new() + { + Country = "EG", + Code = "eg_tin", + Description = "Egyptian Tax Identification Number", + Example = "123456789", + ValidationExpression = new Regex("^[0-9]{9}$") + }, + + new() + { + Country = "SV", + Code = "sv_nit", + Description = "El Salvadorian NIT number", + Example = "1234-567890-123-4", + ValidationExpression = new Regex("^[0-9]{4}-?[0-9]{6}-?[0-9]{3}-?[0-9]{1}$") + }, + + new() + { + Country = "EE", + Code = "eu_vat", + Description = "European VAT number (Estonia)", + Example = "EE123456789", + ValidationExpression = new Regex("^EE[0-9]{9}$") + }, + + new() + { + Country = "EU", + Code = "eu_oss_vat", + Description = "European One Stop Shop VAT number for non-Union scheme", + Example = "EU123456789", + ValidationExpression = new Regex("^EU[0-9]{9}$") + }, + new() + { + Country = "FI", + Code = "eu_vat", + Description = "European VAT number (Finland)", + Example = "FI12345678", + ValidationExpression = new Regex("^FI[0-9]{8}$") + }, + new() + { + Country = "FR", + Code = "eu_vat", + Description = "European VAT number (France)", + Example = "FR12345678901", + ValidationExpression = new Regex("^FR[0-9A-Z]{2}[0-9]{9}$") + }, + new() + { + Country = "GE", + Code = "ge_vat", + Description = "Georgian VAT", + Example = "123456789", + ValidationExpression = new Regex("^[0-9]{9}$") + }, + new() + { + Country = "DE", + Code = "de_stn", + Description = "German Tax Number (Steuernummer)", + Example = "1234567890", + ValidationExpression = new Regex("^[0-9]{10}$") + }, + new() + { + Country = "DE", + Code = "eu_vat", + Description = "European VAT number (Germany)", + Example = "DE123456789", + ValidationExpression = new Regex("^DE[0-9]{9}$") + }, + new() + { + Country = "GR", + Code = "eu_vat", + Description = "European VAT number (Greece)", + Example = "EL123456789", + ValidationExpression = new Regex("^EL[0-9]{9}$") + }, + new() + { + Country = "HK", + Code = "hk_br", + Description = "Hong Kong BR number", + Example = "12345678", + ValidationExpression = new Regex("^[0-9]{8}$") + }, + new() + { + Country = "HU", + Code = "eu_vat", + Description = "European VAT number (Hungaria)", + Example = "HU12345678", + ValidationExpression = new Regex("^HU[0-9]{8}$") + }, + new() + { + Country = "HU", + Code = "hu_tin", + Description = "Hungary tax number (adószám)", + Example = "12345678-1-23", + ValidationExpression = new Regex("^[0-9]{8}-?[0-9]-?[0-9]{2}$") + }, + new() + { + Country = "IS", + Code = "is_vat", + Description = "Icelandic VAT", + Example = "123456", + ValidationExpression = new Regex("^[0-9]{6}$") + }, + new() + { + Country = "IN", + Code = "in_gst", + Description = "Indian GST number", + Example = "12ABCDE3456FGZH", + ValidationExpression = new Regex("^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$") + }, + new() + { + Country = "ID", + Code = "id_npwp", + Description = "Indonesian NPWP number", + Example = "012.345.678.9-012.345", + ValidationExpression = new Regex("^[0-9]{3}.?[0-9]{3}.?[0-9]{3}.?[0-9]{1}-?[0-9]{3}.?[0-9]{3}$") + }, + new() + { + Country = "IE", + Code = "eu_vat", + Description = "European VAT number (Ireland)", + Example = "IE1234567AB", + ValidationExpression = new Regex("^IE[0-9]{7}[A-Z]{1,2}$") + }, + new() + { + Country = "IL", + Code = "il_vat", + Description = "Israel VAT", + Example = "000012345", + ValidationExpression = new Regex("^[0-9]{9}$") + }, + new() + { + Country = "IT", + Code = "eu_vat", + Description = "European VAT number (Italy)", + Example = "IT12345678912", + ValidationExpression = new Regex("^IT[0-9]{11}$") + }, + new() + { + Country = "JP", + Code = "jp_cn", + Description = "Japanese Corporate Number (*Hōjin Bangō*)", + Example = "1234567891234", + ValidationExpression = new Regex("^[0-9]{13}$") + }, + new() + { + Country = "JP", + Code = "jp_rn", + Description = + "Japanese Registered Foreign Businesses' Registration Number (*Tōroku Kokugai Jigyōsha no Tōroku Bangō*)", + Example = "12345", + ValidationExpression = new Regex("^[0-9]{5}$") + }, + new() + { + Country = "JP", + Code = "jp_trn", + Description = "Japanese Tax Registration Number (*Tōroku Bangō*)", + Example = "T1234567891234", + ValidationExpression = new Regex("^T[0-9]{13}$") + }, + new() + { + Country = "KZ", + Code = "kz_bin", + Description = "Kazakhstani Business Identification Number", + Example = "123456789012", + ValidationExpression = new Regex("^[0-9]{12}$") + }, + new() + { + Country = "KE", + Code = "ke_pin", + Description = "Kenya Revenue Authority Personal Identification Number", + Example = "P000111111A", + ValidationExpression = new Regex("^[A-Z]{1}[0-9]{9}[A-Z]{1}$") + }, + new() + { + Country = "LV", + Code = "eu_vat", + Description = "European VAT number", + Example = "LV12345678912", + ValidationExpression = new Regex("^LV[0-9]{11}$") + }, + new() + { + Country = "LI", + Code = "li_uid", + Description = "Liechtensteinian UID number", + Example = "CHE123456789", + ValidationExpression = new Regex("^CHE[0-9]{9}$") + }, + new() + { + Country = "LI", + Code = "li_vat", + Description = "Liechtensteinian VAT number", + Example = "12345", + ValidationExpression = new Regex("^[0-9]{5}$") + }, + new() + { + Country = "LT", + Code = "eu_vat", + Description = "European VAT number (Lithuania)", + Example = "LT123456789123", + ValidationExpression = new Regex("^LT[0-9]{9,12}$") + }, + new() + { + Country = "LU", + Code = "eu_vat", + Description = "European VAT number (Luxembourg)", + Example = "LU12345678", + ValidationExpression = new Regex("^LU[0-9]{8}$") + }, + new() + { + Country = "MY", + Code = "my_frp", + Description = "Malaysian FRP number", + Example = "12345678", + ValidationExpression = new Regex("^[0-9]{8}$") + }, + new() + { + Country = "MY", + Code = "my_itn", + Description = "Malaysian ITN", + Example = "C 1234567890", + ValidationExpression = new Regex("^[A-Z]{1} ?[0-9]{10}$") + }, + new() + { + Country = "MY", + Code = "my_sst", + Description = "Malaysian SST number", + Example = "A12-3456-78912345", + ValidationExpression = new Regex("^[A-Z]{1}[0-9]{2}-?[0-9]{4}-?[0-9]{8}$") + }, + new() + { + Country = "MT", + Code = "eu_vat", + Description = "European VAT number (Malta)", + Example = "MT12345678", + ValidationExpression = new Regex("^MT[0-9]{8}$") + }, + new() + { + Country = "MX", + Code = "mx_rfc", + Description = "Mexican RFC number", + Example = "ABC010203AB9", + ValidationExpression = new Regex("^[A-Z]{3}[0-9]{6}[A-Z0-9]{3}$") + }, + new() + { + Country = "MD", + Code = "md_vat", + Description = "Moldova VAT Number", + Example = "1234567", + ValidationExpression = new Regex("^[0-9]{7}$") + }, + new() + { + Country = "MA", + Code = "ma_vat", + Description = "Morocco VAT Number", + Example = "12345678", + ValidationExpression = new Regex("^[0-9]{8}$") + }, + new() + { + Country = "NL", + Code = "eu_vat", + Description = "European VAT number (Netherlands)", + Example = "NL123456789B12", + ValidationExpression = new Regex("^NL[0-9]{9}B[0-9]{2}$") + }, + new() + { + Country = "NZ", + Code = "nz_gst", + Description = "New Zealand GST number", + Example = "123456789", + ValidationExpression = new Regex("^[0-9]{9}$") + }, + new() + { + Country = "NG", + Code = "ng_tin", + Description = "Nigerian TIN Number", + Example = "12345678-0001", + ValidationExpression = new Regex("^[0-9]{8}-[0-9]{4}$") + }, + new() + { + Country = "NO", + Code = "no_vat", + Description = "Norwegian VAT number", + Example = "123456789MVA", + ValidationExpression = new Regex("^[0-9]{9}MVA$") + }, + new() + { + Country = "NO", + Code = "no_voec", + Description = "Norwegian VAT on e-commerce number", + Example = "1234567", + ValidationExpression = new Regex("^[0-9]{7}$") + }, + new() + { + Country = "OM", + Code = "om_vat", + Description = "Omani VAT Number", + Example = "OM1234567890", + ValidationExpression = new Regex("^OM[0-9]{10}$") + }, + new() + { + Country = "PE", + Code = "pe_ruc", + Description = "Peruvian RUC number", + Example = "12345678901", + ValidationExpression = new Regex("^[0-9]{11}$") + }, + new() + { + Country = "PH", + Code = "ph_tin", + Description = "Philippines Tax Identification Number", + Example = "123456789012", + ValidationExpression = new Regex("^[0-9]{12}$") + }, + new() + { + Country = "PL", + Code = "eu_vat", + Description = "European VAT number (Poland)", + Example = "PL1234567890", + ValidationExpression = new Regex("^PL[0-9]{10}$") + }, + new() + { + Country = "PT", + Code = "eu_vat", + Description = "European VAT number (Portugal)", + Example = "PT123456789", + ValidationExpression = new Regex("^PT[0-9]{9}$") + }, + new() + { + Country = "RO", + Code = "eu_vat", + Description = "European VAT number (Romania)", + Example = "RO1234567891", + ValidationExpression = new Regex("^RO[0-9]{2,10}$") + }, + new() + { + Country = "RO", + Code = "ro_tin", + Description = "Romanian tax ID number", + Example = "1234567890123", + ValidationExpression = new Regex("^[0-9]{13}$") + }, + new() + { + Country = "RU", + Code = "ru_inn", + Description = "Russian INN", + Example = "1234567891", + ValidationExpression = new Regex("^[0-9]{10,12}$") + }, + new() + { + Country = "RU", + Code = "ru_kpp", + Description = "Russian KPP", + Example = "123456789", + ValidationExpression = new Regex("^[0-9]{9}$") + }, + new() + { + Country = "SA", + Code = "sa_vat", + Description = "Saudi Arabia VAT", + Example = "123456789012345", + ValidationExpression = new Regex("^[0-9]{15}$") + }, + new() + { + Country = "RS", + Code = "rs_pib", + Description = "Serbian PIB number", + Example = "123456789", + ValidationExpression = new Regex("^[0-9]{9}$") + }, + new() + { + Country = "SG", + Code = "sg_gst", + Description = "Singaporean GST", + Example = "M12345678X", + ValidationExpression = new Regex("^[A-Z]{1}[0-9]{8}[A-Z]{1}$") + }, + new() + { + Country = "SG", + Code = "sg_uen", + Description = "Singaporean UEN", + Example = "123456789F", + ValidationExpression = new Regex("^[0-9]{9}[A-Z]{1}$") + }, + new() + { + Country = "SK", + Code = "eu_vat", + Description = "European VAT number (Slovakia)", + Example = "SK1234567891", + ValidationExpression = new Regex("^SK[0-9]{10}$") + }, + new() + { + Country = "SI", + Code = "eu_vat", + Description = "European VAT number (Slovenia)", + Example = "SI12345678", + ValidationExpression = new Regex("^SI[0-9]{8}$") + }, + new() + { + Country = "SI", + Code = "si_tin", + Description = "Slovenia tax number (davčna številka)", + Example = "12345678", + ValidationExpression = new Regex("^[0-9]{8}$") + }, + new() + { + Country = "ZA", + Code = "za_vat", + Description = "South African VAT number", + Example = "4123456789", + ValidationExpression = new Regex("^[0-9]{10}$") + }, + new() + { + Country = "KR", + Code = "kr_brn", + Description = "Korean BRN", + Example = "123-45-67890", + ValidationExpression = new Regex("^[0-9]{3}-?[0-9]{2}-?[0-9]{5}$") + }, + new() + { + Country = "ES", + Code = "es_cif", + Description = "Spanish NIF/CIF number", + Example = "A12345678", + ValidationExpression = new Regex("^[A-Z]{1}[0-9]{8}$") + }, + new() + { + Country = "ES", + Code = "eu_vat", + Description = "European VAT number (Spain)", + Example = "ESA1234567Z", + ValidationExpression = new Regex("^ES[A-Z]{1}[0-9]{7}[A-Z]{1}$") + }, + new() + { + Country = "SE", + Code = "eu_vat", + Description = "European VAT number (Sweden)", + Example = "SE123456789123", + ValidationExpression = new Regex("^SE[0-9]{12}$") + }, + new() + { + Country = "CH", + Code = "ch_uid", + Description = "Switzerland UID number", + Example = "CHE-123.456.789 HR", + ValidationExpression = new Regex("^CHE-?[0-9]{3}.?[0-9]{3}.?[0-9]{3} ?HR$") + }, + new() + { + Country = "CH", + Code = "ch_vat", + Description = "Switzerland VAT number", + Example = "CHE-123.456.789 MWST", + ValidationExpression = new Regex("^CHE-?[0-9]{3}.?[0-9]{3}.?[0-9]{3} ?MWST$") + }, + new() + { + Country = "TW", + Code = "tw_vat", + Description = "Taiwanese VAT", + Example = "12345678", + ValidationExpression = new Regex("^[0-9]{8}$") + }, + new() + { + Country = "TZ", + Code = "tz_vat", + Description = "Tanzania VAT Number", + Example = "12345678A", + ValidationExpression = new Regex("^[0-9]{8}[A-Z]{1}$") + }, + new() + { + Country = "TH", + Code = "th_vat", + Description = "Thai VAT", + Example = "1234567891234", + ValidationExpression = new Regex("^[0-9]{13}$") + }, + new() + { + Country = "TR", + Code = "tr_tin", + Description = "Turkish TIN Number", + Example = "0123456789", + ValidationExpression = new Regex("^[0-9]{10}$") + }, + new() + { + Country = "UA", + Code = "ua_vat", + Description = "Ukrainian VAT", + Example = "123456789", + ValidationExpression = new Regex("^[0-9]{9}$") + }, + new() + { + Country = "AE", + Code = "ae_trn", + Description = "United Arab Emirates TRN", + Example = "123456789012345", + ValidationExpression = new Regex("^[0-9]{15}$") + }, + new() + { + Country = "GB", + Code = "eu_vat", + Description = "Northern Ireland VAT number", + Example = "XI123456789", + ValidationExpression = new Regex("^XI[0-9]{9}$") + }, + new() + { + Country = "GB", + Code = "gb_vat", + Description = "United Kingdom VAT number", + Example = "GB123456789", + ValidationExpression = new Regex("^GB[0-9]{9}$") + }, + new() + { + Country = "US", + Code = "us_ein", + Description = "United States EIN", + Example = "12-3456789", + ValidationExpression = new Regex("^[0-9]{2}-?[0-9]{7}$") + }, + new() + { + Country = "UY", + Code = "uy_ruc", + Description = "Uruguayan RUC number", + Example = "123456789012", + ValidationExpression = new Regex("^[0-9]{12}$") + }, + new() + { + Country = "UZ", + Code = "uz_tin", + Description = "Uzbekistan TIN Number", + Example = "123456789", + ValidationExpression = new Regex("^[0-9]{9}$") + }, + new() + { + Country = "UZ", + Code = "uz_vat", + Description = "Uzbekistan VAT Number", + Example = "123456789012", + ValidationExpression = new Regex("^[0-9]{12}$") + }, + new() + { + Country = "VE", + Code = "ve_rif", + Description = "Venezuelan RIF number", + Example = "A-12345678-9", + ValidationExpression = new Regex("^[A-Z]{1}-?[0-9]{8}-?[0-9]{1}$") + }, + new() + { + Country = "VN", + Code = "vn_tin", + Description = "Vietnamese tax ID number", + Example = "1234567890", + ValidationExpression = new Regex("^[0-9]{10}$") + } + ]; + + public string GetStripeTaxCode(string country, string taxId) + { + foreach (var taxIdType in _taxIdTypes.Where(x => x.Country == country)) + { + if (taxIdType.ValidationExpression.IsMatch(taxId)) + { + return taxIdType.Code; + } + } + + return null; + } + + public bool IsSupported(string country) + { + return _taxIdTypes.Any(x => x.Country == country); + } +} diff --git a/src/Core/Billing/Utilities.cs b/src/Core/Billing/Utilities.cs index 28527af0c0..695a3b1bb4 100644 --- a/src/Core/Billing/Utilities.cs +++ b/src/Core/Billing/Utilities.cs @@ -83,6 +83,7 @@ public static class Utilities customer.Address.Country, customer.Address.PostalCode, customer.TaxIds?.FirstOrDefault()?.Value, + customer.TaxIds?.FirstOrDefault()?.Type, customer.Address.Line1, customer.Address.Line2, customer.Address.City, diff --git a/src/Core/Models/Business/TaxInfo.cs b/src/Core/Models/Business/TaxInfo.cs index 4424576ec9..b12c5229b3 100644 --- a/src/Core/Models/Business/TaxInfo.cs +++ b/src/Core/Models/Business/TaxInfo.cs @@ -2,18 +2,9 @@ public class TaxInfo { - private string _taxIdNumber = null; - private string _taxIdType = null; + public string TaxIdNumber { get; set; } + public string TaxIdType { get; set; } - public string TaxIdNumber - { - get => _taxIdNumber; - set - { - _taxIdNumber = value; - _taxIdType = null; - } - } public string StripeTaxRateId { get; set; } public string BillingAddressLine1 { get; set; } public string BillingAddressLine2 { get; set; } @@ -21,201 +12,6 @@ public class TaxInfo public string BillingAddressState { get; set; } public string BillingAddressPostalCode { get; set; } public string BillingAddressCountry { get; set; } = "US"; - public string TaxIdType - { - get - { - if (string.IsNullOrWhiteSpace(BillingAddressCountry) || - string.IsNullOrWhiteSpace(TaxIdNumber)) - { - return null; - } - if (!string.IsNullOrWhiteSpace(_taxIdType)) - { - return _taxIdType; - } - - switch (BillingAddressCountry.ToUpper()) - { - case "AD": - _taxIdType = "ad_nrt"; - break; - case "AE": - _taxIdType = "ae_trn"; - break; - case "AR": - _taxIdType = "ar_cuit"; - break; - case "AU": - _taxIdType = "au_abn"; - break; - case "BO": - _taxIdType = "bo_tin"; - break; - case "BR": - _taxIdType = "br_cnpj"; - break; - case "CA": - // May break for those in Québec given the assumption of QST - if (BillingAddressState?.Contains("bec") ?? false) - { - _taxIdType = "ca_qst"; - break; - } - _taxIdType = "ca_bn"; - break; - case "CH": - _taxIdType = "ch_vat"; - break; - case "CL": - _taxIdType = "cl_tin"; - break; - case "CN": - _taxIdType = "cn_tin"; - break; - case "CO": - _taxIdType = "co_nit"; - break; - case "CR": - _taxIdType = "cr_tin"; - break; - case "DO": - _taxIdType = "do_rcn"; - break; - case "EC": - _taxIdType = "ec_ruc"; - break; - case "EG": - _taxIdType = "eg_tin"; - break; - case "GE": - _taxIdType = "ge_vat"; - break; - case "ID": - _taxIdType = "id_npwp"; - break; - case "IL": - _taxIdType = "il_vat"; - break; - case "IS": - _taxIdType = "is_vat"; - break; - case "KE": - _taxIdType = "ke_pin"; - break; - case "AT": - case "BE": - case "BG": - case "CY": - case "CZ": - case "DE": - case "DK": - case "EE": - case "ES": - case "FI": - case "FR": - case "GB": - case "GR": - case "HR": - case "HU": - case "IE": - case "IT": - case "LT": - case "LU": - case "LV": - case "MT": - case "NL": - case "PL": - case "PT": - case "RO": - case "SE": - case "SI": - case "SK": - _taxIdType = "eu_vat"; - break; - case "HK": - _taxIdType = "hk_br"; - break; - case "IN": - _taxIdType = "in_gst"; - break; - case "JP": - _taxIdType = "jp_cn"; - break; - case "KR": - _taxIdType = "kr_brn"; - break; - case "LI": - _taxIdType = "li_uid"; - break; - case "MX": - _taxIdType = "mx_rfc"; - break; - case "MY": - _taxIdType = "my_sst"; - break; - case "NO": - _taxIdType = "no_vat"; - break; - case "NZ": - _taxIdType = "nz_gst"; - break; - case "PE": - _taxIdType = "pe_ruc"; - break; - case "PH": - _taxIdType = "ph_tin"; - break; - case "RS": - _taxIdType = "rs_pib"; - break; - case "RU": - _taxIdType = "ru_inn"; - break; - case "SA": - _taxIdType = "sa_vat"; - break; - case "SG": - _taxIdType = "sg_gst"; - break; - case "SV": - _taxIdType = "sv_nit"; - break; - case "TH": - _taxIdType = "th_vat"; - break; - case "TR": - _taxIdType = "tr_tin"; - break; - case "TW": - _taxIdType = "tw_vat"; - break; - case "UA": - _taxIdType = "ua_vat"; - break; - case "US": - _taxIdType = "us_ein"; - break; - case "UY": - _taxIdType = "uy_ruc"; - break; - case "VE": - _taxIdType = "ve_rif"; - break; - case "VN": - _taxIdType = "vn_tin"; - break; - case "ZA": - _taxIdType = "za_vat"; - break; - default: - _taxIdType = null; - break; - } - - return _taxIdType; - } - } public bool HasTaxId { diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index bf9d047029..7d0f9d3c63 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -1,6 +1,9 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Models.Api.Requests.Accounts; +using Bit.Core.Billing.Models.Api.Requests.Organizations; +using Bit.Core.Billing.Models.Api.Responses; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; @@ -59,4 +62,7 @@ public interface IPaymentService Task RisksSubscriptionFailure(Organization organization); Task HasSecretsManagerStandalone(Organization organization); Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Stripe.Subscription subscription); + Task PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); + Task PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); + } diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index 30583ef0b3..ef2e3ab766 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -31,6 +31,7 @@ public interface IStripeAdapter 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); @@ -42,6 +43,7 @@ public interface IStripeAdapter IAsyncEnumerable PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options); Task PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null); Task PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null); + Task PlanGetAsync(string id, Stripe.PlanGetOptions options = null); Task TaxRateCreateAsync(Stripe.TaxRateCreateOptions options); Task TaxRateUpdateAsync(string id, Stripe.TaxRateUpdateOptions options); Task TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options); diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index 8d18331456..f4f8efe75f 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -15,6 +15,7 @@ public class StripeAdapter : IStripeAdapter 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 Stripe.TestHelpers.TestClockService _testClockService; @@ -33,6 +34,7 @@ public class StripeAdapter : IStripeAdapter _cardService = new Stripe.CardService(); _bankAccountService = new Stripe.BankAccountService(); _priceService = new Stripe.PriceService(); + _planService = new Stripe.PlanService(); _setupIntentService = new SetupIntentService(); _testClockService = new Stripe.TestHelpers.TestClockService(); _customerBalanceTransactionService = new CustomerBalanceTransactionService(); @@ -133,6 +135,11 @@ public class StripeAdapter : IStripeAdapter return invoices; } + public Task InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options) + { + return _invoiceService.CreatePreviewAsync(options); + } + public async Task> InvoiceSearchAsync(InvoiceSearchOptions options) => (await _invoiceService.SearchAsync(options)).Data; @@ -184,6 +191,11 @@ public class StripeAdapter : IStripeAdapter return _paymentMethodService.DetachAsync(id, options); } + public Task PlanGetAsync(string id, Stripe.PlanGetOptions options = null) + { + return _planService.GetAsync(id, options); + } + public Task TaxRateCreateAsync(Stripe.TaxRateCreateOptions options) { return _taxRateService.CreateAsync(options); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 259a4eb757..c32dcd43ad 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1,8 +1,13 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Models.Api.Requests.Accounts; +using Bit.Core.Billing.Models.Api.Requests.Organizations; +using Bit.Core.Billing.Models.Api.Responses; using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -32,6 +37,7 @@ public class StripePaymentService : IPaymentService private readonly IStripeAdapter _stripeAdapter; private readonly IGlobalSettings _globalSettings; private readonly IFeatureService _featureService; + private readonly ITaxService _taxService; public StripePaymentService( ITransactionRepository transactionRepository, @@ -40,7 +46,8 @@ public class StripePaymentService : IPaymentService IStripeAdapter stripeAdapter, Braintree.IBraintreeGateway braintreeGateway, IGlobalSettings globalSettings, - IFeatureService featureService) + IFeatureService featureService, + ITaxService taxService) { _transactionRepository = transactionRepository; _logger = logger; @@ -49,6 +56,7 @@ public class StripePaymentService : IPaymentService _btGateway = braintreeGateway; _globalSettings = globalSettings; _featureService = featureService; + _taxService = taxService; } public async Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, @@ -112,6 +120,20 @@ public class StripePaymentService : IPaymentService Subscription subscription; try { + if (taxInfo.TaxIdNumber != null && taxInfo.TaxIdType == null) + { + taxInfo.TaxIdType = _taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry, + taxInfo.TaxIdNumber); + + if (taxInfo.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"); + } + } + var customerCreateOptions = new CustomerCreateOptions { Description = org.DisplayBusinessName(), @@ -146,12 +168,9 @@ public class StripePaymentService : IPaymentService City = taxInfo?.BillingAddressCity, State = taxInfo?.BillingAddressState, }, - TaxIdData = taxInfo?.HasTaxId != true - ? null - : - [ - new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber, } - ], + TaxIdData = taxInfo.HasTaxId + ? [new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber }] + : null }; customerCreateOptions.AddExpand("tax"); @@ -1659,6 +1678,7 @@ public class StripePaymentService : IPaymentService return new TaxInfo { TaxIdNumber = taxId?.Value, + TaxIdType = taxId?.Type, BillingAddressLine1 = address?.Line1, BillingAddressLine2 = address?.Line2, BillingAddressCity = address?.City, @@ -1670,9 +1690,13 @@ public class StripePaymentService : IPaymentService public async Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo) { - if (subscriber != null && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) + if (string.IsNullOrWhiteSpace(subscriber?.GatewayCustomerId) || subscriber.IsUser()) { - var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions + return; + } + + var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, + new CustomerUpdateOptions { Address = new AddressOptions { @@ -1686,23 +1710,59 @@ public class StripePaymentService : IPaymentService Expand = ["tax_ids"] }); - if (!subscriber.IsUser() && customer != null) - { - var taxId = customer.TaxIds?.FirstOrDefault(); + if (customer == null) + { + return; + } - if (taxId != null) - { - await _stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); - } - if (!string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber) && - !string.IsNullOrWhiteSpace(taxInfo.TaxIdType)) - { - await _stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions - { - Type = taxInfo.TaxIdType, - Value = taxInfo.TaxIdNumber, - }); - } + var taxId = customer.TaxIds?.FirstOrDefault(); + + if (taxId != null) + { + await _stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); + } + + if (string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber)) + { + return; + } + + var taxIdType = taxInfo.TaxIdType; + + if (string.IsNullOrWhiteSpace(taxIdType)) + { + 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"); + } + } + + try + { + await _stripeAdapter.TaxIdCreateAsync(customer.Id, + new TaxIdCreateOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber, }); + } + catch (StripeException e) + { + switch (e.StripeError.Code) + { + case StripeConstants.ErrorCodes.TaxIdInvalid: + _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", + taxInfo.TaxIdNumber, + taxInfo.BillingAddressCountry); + throw new BadRequestException("billingInvalidTaxIdError"); + default: + _logger.LogError(e, + "Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.", + taxInfo.TaxIdNumber, + taxInfo.BillingAddressCountry, + customer.Id); + throw new BadRequestException("billingTaxIdCreationError"); } } } @@ -1835,6 +1895,285 @@ public class StripePaymentService : IPaymentService } } + public async Task PreviewInvoiceAsync( + PreviewIndividualInvoiceRequestBody parameters, + string gatewayCustomerId, + string gatewaySubscriptionId) + { + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions + { + Enabled = true, + }, + Currency = "usd", + Discounts = new List(), + SubscriptionDetails = new InvoiceSubscriptionDetailsOptions + { + Items = + [ + new() + { + Quantity = 1, + Plan = "premium-annually" + }, + + new() + { + Quantity = parameters.PasswordManager.AdditionalStorage, + Plan = "storage-gb-annually" + } + ] + }, + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions + { + PostalCode = parameters.TaxInformation.PostalCode, + Country = parameters.TaxInformation.Country, + } + }, + }; + + if (!string.IsNullOrEmpty(parameters.TaxInformation.TaxId)) + { + var taxIdType = _taxService.GetStripeTaxCode( + options.CustomerDetails.Address.Country, + parameters.TaxInformation.TaxId); + + if (taxIdType == null) + { + _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", + parameters.TaxInformation.TaxId, + parameters.TaxInformation.Country); + throw new BadRequestException("billingPreviewInvalidTaxIdError"); + } + + options.CustomerDetails.TaxIds = [ + new InvoiceCustomerDetailsTaxIdOptions + { + Type = taxIdType, + Value = parameters.TaxInformation.TaxId + } + ]; + } + + if (gatewayCustomerId != null) + { + var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); + + if (gatewayCustomer.Discount != null) + { + options.Discounts.Add(new InvoiceDiscountOptions + { + Discount = gatewayCustomer.Discount.Id + }); + } + + if (gatewaySubscriptionId != null) + { + var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId); + + if (gatewaySubscription?.Discount != null) + { + options.Discounts.Add(new InvoiceDiscountOptions + { + Discount = gatewaySubscription.Discount.Id + }); + } + } + } + + try + { + var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); + + var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null + ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() + : 0M; + + var result = new PreviewInvoiceResponseModel( + effectiveTaxRate, + invoice.TotalExcludingTax.ToMajor() ?? 0, + invoice.Tax.ToMajor() ?? 0, + invoice.Total.ToMajor()); + return result; + } + catch (StripeException e) + { + switch (e.StripeError.Code) + { + case StripeConstants.ErrorCodes.TaxIdInvalid: + _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", + parameters.TaxInformation.TaxId, + parameters.TaxInformation.Country); + throw new BadRequestException("billingPreviewInvalidTaxIdError"); + default: + _logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", + parameters.TaxInformation.TaxId, + parameters.TaxInformation.Country); + throw new BadRequestException("billingPreviewInvoiceError"); + } + } + } + + public async Task PreviewInvoiceAsync( + PreviewOrganizationInvoiceRequestBody parameters, + string gatewayCustomerId, + string gatewaySubscriptionId) + { + var plan = Utilities.StaticStore.GetPlan(parameters.PasswordManager.Plan); + + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions + { + Enabled = true, + }, + Currency = "usd", + Discounts = new List(), + SubscriptionDetails = new InvoiceSubscriptionDetailsOptions + { + Items = + [ + new() + { + Quantity = parameters.PasswordManager.AdditionalStorage, + Plan = plan.PasswordManager.StripeStoragePlanId + } + ] + }, + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions + { + PostalCode = parameters.TaxInformation.PostalCode, + Country = parameters.TaxInformation.Country, + } + }, + }; + + if (plan.PasswordManager.HasAdditionalSeatsOption) + { + options.SubscriptionDetails.Items.Add( + new() + { + Quantity = parameters.PasswordManager.Seats, + Plan = plan.PasswordManager.StripeSeatPlanId + } + ); + } + else + { + options.SubscriptionDetails.Items.Add( + new() + { + Quantity = 1, + Plan = plan.PasswordManager.StripePlanId + } + ); + } + + if (plan.SupportsSecretsManager) + { + if (plan.SecretsManager.HasAdditionalSeatsOption) + { + options.SubscriptionDetails.Items.Add(new() + { + Quantity = parameters.SecretsManager?.Seats ?? 0, + Plan = plan.SecretsManager.StripeSeatPlanId + }); + } + + if (plan.SecretsManager.HasAdditionalServiceAccountOption) + { + options.SubscriptionDetails.Items.Add(new() + { + Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, + Plan = plan.SecretsManager.StripeServiceAccountPlanId + }); + } + } + + if (!string.IsNullOrEmpty(parameters.TaxInformation.TaxId)) + { + var taxIdType = _taxService.GetStripeTaxCode( + options.CustomerDetails.Address.Country, + parameters.TaxInformation.TaxId); + + if (taxIdType == null) + { + _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", + parameters.TaxInformation.TaxId, + parameters.TaxInformation.Country); + throw new BadRequestException("billingTaxIdTypeInferenceError"); + } + + options.CustomerDetails.TaxIds = [ + new InvoiceCustomerDetailsTaxIdOptions + { + Type = taxIdType, + Value = parameters.TaxInformation.TaxId + } + ]; + } + + if (gatewayCustomerId != null) + { + var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); + + if (gatewayCustomer.Discount != null) + { + options.Discounts.Add(new InvoiceDiscountOptions + { + Discount = gatewayCustomer.Discount.Id + }); + } + + var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId); + + if (gatewaySubscription?.Discount != null) + { + options.Discounts.Add(new InvoiceDiscountOptions + { + Discount = gatewaySubscription.Discount.Id + }); + } + } + + try + { + var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); + + var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null + ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() + : 0M; + + var result = new PreviewInvoiceResponseModel( + effectiveTaxRate, + invoice.TotalExcludingTax.ToMajor() ?? 0, + invoice.Tax.ToMajor() ?? 0, + invoice.Total.ToMajor()); + return result; + } + catch (StripeException e) + { + switch (e.StripeError.Code) + { + case StripeConstants.ErrorCodes.TaxIdInvalid: + _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", + parameters.TaxInformation.TaxId, + parameters.TaxInformation.Country); + throw new BadRequestException("billingPreviewInvalidTaxIdError"); + default: + _logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", + parameters.TaxInformation.TaxId, + parameters.TaxInformation.Country); + throw new BadRequestException("billingPreviewInvoiceError"); + } + } + } + private PaymentMethod GetLatestCardPaymentMethod(string customerId) { var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging( diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 385b185ffe..9c25ffdc55 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -1545,7 +1545,7 @@ public class SubscriberServiceTests { var stripeAdapter = sutProvider.GetDependency(); - var customer = new Customer { Id = provider.GatewayCustomerId, TaxIds = new StripeList { Data = [new TaxId { Id = "tax_id_1" }] } }; + var customer = new Customer { Id = provider.GatewayCustomerId, TaxIds = new StripeList { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] } }; stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId, Arg.Is( options => options.Expand.Contains("tax_ids"))).Returns(customer); @@ -1554,6 +1554,7 @@ public class SubscriberServiceTests "US", "12345", "123456789", + "us_ein", "123 Example St.", null, "Example Town", diff --git a/test/Core.Test/Models/Business/TaxInfoTests.cs b/test/Core.Test/Models/Business/TaxInfoTests.cs deleted file mode 100644 index 197948006e..0000000000 --- a/test/Core.Test/Models/Business/TaxInfoTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Bit.Core.Models.Business; -using Xunit; - -namespace Bit.Core.Test.Models.Business; - -public class TaxInfoTests -{ - // PH = Placeholder - [Theory] - [InlineData(null, null, null, null)] - [InlineData("", "", null, null)] - [InlineData("PH", "", null, null)] - [InlineData("", "PH", null, null)] - [InlineData("AE", "PH", null, "ae_trn")] - [InlineData("AU", "PH", null, "au_abn")] - [InlineData("BR", "PH", null, "br_cnpj")] - [InlineData("CA", "PH", "bec", "ca_qst")] - [InlineData("CA", "PH", null, "ca_bn")] - [InlineData("CL", "PH", null, "cl_tin")] - [InlineData("AT", "PH", null, "eu_vat")] - [InlineData("BE", "PH", null, "eu_vat")] - [InlineData("BG", "PH", null, "eu_vat")] - [InlineData("CY", "PH", null, "eu_vat")] - [InlineData("CZ", "PH", null, "eu_vat")] - [InlineData("DE", "PH", null, "eu_vat")] - [InlineData("DK", "PH", null, "eu_vat")] - [InlineData("EE", "PH", null, "eu_vat")] - [InlineData("ES", "PH", null, "eu_vat")] - [InlineData("FI", "PH", null, "eu_vat")] - [InlineData("FR", "PH", null, "eu_vat")] - [InlineData("GB", "PH", null, "eu_vat")] - [InlineData("GR", "PH", null, "eu_vat")] - [InlineData("HR", "PH", null, "eu_vat")] - [InlineData("HU", "PH", null, "eu_vat")] - [InlineData("IE", "PH", null, "eu_vat")] - [InlineData("IT", "PH", null, "eu_vat")] - [InlineData("LT", "PH", null, "eu_vat")] - [InlineData("LU", "PH", null, "eu_vat")] - [InlineData("LV", "PH", null, "eu_vat")] - [InlineData("MT", "PH", null, "eu_vat")] - [InlineData("NL", "PH", null, "eu_vat")] - [InlineData("PL", "PH", null, "eu_vat")] - [InlineData("PT", "PH", null, "eu_vat")] - [InlineData("RO", "PH", null, "eu_vat")] - [InlineData("SE", "PH", null, "eu_vat")] - [InlineData("SI", "PH", null, "eu_vat")] - [InlineData("SK", "PH", null, "eu_vat")] - [InlineData("HK", "PH", null, "hk_br")] - [InlineData("IN", "PH", null, "in_gst")] - [InlineData("JP", "PH", null, "jp_cn")] - [InlineData("KR", "PH", null, "kr_brn")] - [InlineData("LI", "PH", null, "li_uid")] - [InlineData("MX", "PH", null, "mx_rfc")] - [InlineData("MY", "PH", null, "my_sst")] - [InlineData("NO", "PH", null, "no_vat")] - [InlineData("NZ", "PH", null, "nz_gst")] - [InlineData("RU", "PH", null, "ru_inn")] - [InlineData("SA", "PH", null, "sa_vat")] - [InlineData("SG", "PH", null, "sg_gst")] - [InlineData("TH", "PH", null, "th_vat")] - [InlineData("TW", "PH", null, "tw_vat")] - [InlineData("US", "PH", null, "us_ein")] - [InlineData("ZA", "PH", null, "za_vat")] - [InlineData("ABCDEF", "PH", null, null)] - public void GetTaxIdType_Success(string billingAddressCountry, - string taxIdNumber, - string billingAddressState, - string expectedTaxIdType) - { - var taxInfo = new TaxInfo - { - BillingAddressCountry = billingAddressCountry, - TaxIdNumber = taxIdNumber, - BillingAddressState = billingAddressState, - }; - - Assert.Equal(expectedTaxIdType, taxInfo.TaxIdType); - } - - [Fact] - public void GetTaxIdType_CreateOnce_ReturnCacheSecondTime() - { - var taxInfo = new TaxInfo - { - BillingAddressCountry = "US", - TaxIdNumber = "PH", - BillingAddressState = null, - }; - - Assert.Equal("us_ein", taxInfo.TaxIdType); - - // Per the current spec even if the values change to something other than null it - // will return the cached version of TaxIdType. - taxInfo.BillingAddressCountry = "ZA"; - - Assert.Equal("us_ein", taxInfo.TaxIdType); - } - - [Theory] - [InlineData(null, null, false)] - [InlineData("123", "US", true)] - [InlineData("123", "ZQ12", false)] - [InlineData(" ", "US", false)] - public void HasTaxId_ReturnsExpected(string taxIdNumber, string billingAddressCountry, bool expected) - { - var taxInfo = new TaxInfo - { - TaxIdNumber = taxIdNumber, - BillingAddressCountry = billingAddressCountry, - }; - - Assert.Equal(expected, taxInfo.HasTaxId); - } -} diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index e15f07b113..35e1901a2f 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -77,7 +77,8 @@ public class StripePaymentServiceTests c.Address.Line2 == taxInfo.BillingAddressLine2 && c.Address.City == taxInfo.BillingAddressCity && c.Address.State == taxInfo.BillingAddressState && - c.TaxIdData == null + c.TaxIdData.First().Value == taxInfo.TaxIdNumber && + c.TaxIdData.First().Type == taxInfo.TaxIdType )); await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => @@ -134,7 +135,8 @@ public class StripePaymentServiceTests c.Address.Line2 == taxInfo.BillingAddressLine2 && c.Address.City == taxInfo.BillingAddressCity && c.Address.State == taxInfo.BillingAddressState && - c.TaxIdData == null + c.TaxIdData.First().Value == taxInfo.TaxIdNumber && + c.TaxIdData.First().Type == taxInfo.TaxIdType )); await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => @@ -190,7 +192,8 @@ public class StripePaymentServiceTests c.Address.Line2 == taxInfo.BillingAddressLine2 && c.Address.City == taxInfo.BillingAddressCity && c.Address.State == taxInfo.BillingAddressState && - c.TaxIdData == null + c.TaxIdData.First().Value == taxInfo.TaxIdNumber && + c.TaxIdData.First().Type == taxInfo.TaxIdType )); await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => @@ -247,7 +250,8 @@ public class StripePaymentServiceTests c.Address.Line2 == taxInfo.BillingAddressLine2 && c.Address.City == taxInfo.BillingAddressCity && c.Address.State == taxInfo.BillingAddressState && - c.TaxIdData == null + c.TaxIdData.First().Value == taxInfo.TaxIdNumber && + c.TaxIdData.First().Type == taxInfo.TaxIdType )); await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => @@ -441,7 +445,8 @@ public class StripePaymentServiceTests c.Address.Line2 == taxInfo.BillingAddressLine2 && c.Address.City == taxInfo.BillingAddressCity && c.Address.State == taxInfo.BillingAddressState && - c.TaxIdData == null + c.TaxIdData.First().Value == taxInfo.TaxIdNumber && + c.TaxIdData.First().Type == taxInfo.TaxIdType )); await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => @@ -510,7 +515,8 @@ public class StripePaymentServiceTests c.Address.Line2 == taxInfo.BillingAddressLine2 && c.Address.City == taxInfo.BillingAddressCity && c.Address.State == taxInfo.BillingAddressState && - c.TaxIdData == null + c.TaxIdData.First().Value == taxInfo.TaxIdNumber && + c.TaxIdData.First().Type == taxInfo.TaxIdType )); await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => From 470a12640ee9f42b725cdf4d2587a59ec4dc121b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ch=C4=99ci=C5=84ski?= Date: Wed, 4 Dec 2024 14:18:58 +0100 Subject: [PATCH 16/30] Trigger unified build on rc and hotfix-rc branches (#5108) --- .github/workflows/build.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1d092d8b4d..0fa03312b8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -530,7 +530,9 @@ jobs: self-host-build: name: Trigger self-host build - if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') runs-on: ubuntu-22.04 needs: - build-docker From 90a9473a5ecf4e0d2e912bfb7dd7209b310a9673 Mon Sep 17 00:00:00 2001 From: Jonas Hendrickx Date: Wed, 4 Dec 2024 15:36:11 +0100 Subject: [PATCH 17/30] Revert "[PM-13999] Show estimated tax for taxable countries (#5077)" (#5109) This reverts commit 94fdfa40e8af9c9b788aafe2cf89eacc2913eeea. Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> --- .../Billing/TaxServiceTests.cs | 151 --- .../Controllers/AccountsBillingController.cs | 15 - .../Billing/Controllers/InvoicesController.cs | 42 - .../Controllers/ProviderBillingController.cs | 1 - .../Billing/Controllers/StripeController.cs | 14 +- .../Requests/TaxInformationRequestBody.cs | 2 - .../Billing/Extensions/CurrencyExtensions.cs | 33 - .../Extensions/ServiceCollectionExtensions.cs | 1 - .../PreviewIndividualInvoiceRequestModel.cs | 18 - .../PreviewOrganizationInvoiceRequestModel.cs | 37 - .../Requests/TaxInformationRequestModel.cs | 14 - .../Responses/PreviewInvoiceResponseModel.cs | 7 - src/Core/Billing/Models/PreviewInvoiceInfo.cs | 7 - .../Billing/Models/Sales/OrganizationSale.cs | 1 - src/Core/Billing/Models/TaxIdType.cs | 22 - src/Core/Billing/Models/TaxInformation.cs | 160 +++- src/Core/Billing/Services/ITaxService.cs | 22 - .../OrganizationBillingService.cs | 35 +- .../PremiumUserBillingService.cs | 37 +- .../Implementations/SubscriberService.cs | 49 +- src/Core/Billing/Services/TaxService.cs | 901 ------------------ src/Core/Billing/Utilities.cs | 1 - src/Core/Models/Business/TaxInfo.cs | 208 +++- src/Core/Services/IPaymentService.cs | 6 - src/Core/Services/IStripeAdapter.cs | 2 - .../Services/Implementations/StripeAdapter.cs | 12 - .../Implementations/StripePaymentService.cs | 387 +------- .../Services/SubscriberServiceTests.cs | 3 +- .../Core.Test/Models/Business/TaxInfoTests.cs | 114 +++ .../Services/StripePaymentServiceTests.cs | 18 +- 30 files changed, 529 insertions(+), 1791 deletions(-) delete mode 100644 bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs delete mode 100644 src/Api/Billing/Controllers/InvoicesController.cs delete mode 100644 src/Core/Billing/Extensions/CurrencyExtensions.cs delete mode 100644 src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs delete mode 100644 src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs delete mode 100644 src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs delete mode 100644 src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs delete mode 100644 src/Core/Billing/Models/PreviewInvoiceInfo.cs delete mode 100644 src/Core/Billing/Models/TaxIdType.cs delete mode 100644 src/Core/Billing/Services/ITaxService.cs delete mode 100644 src/Core/Billing/Services/TaxService.cs create mode 100644 test/Core.Test/Models/Business/TaxInfoTests.cs diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs deleted file mode 100644 index 3995fb9de6..0000000000 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/TaxServiceTests.cs +++ /dev/null @@ -1,151 +0,0 @@ -using Bit.Core.Billing.Services; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using Xunit; - -namespace Bit.Commercial.Core.Test.Billing; - -[SutProviderCustomize] -public class TaxServiceTests -{ - [Theory] - [BitAutoData("AD", "A-123456-Z", "ad_nrt")] - [BitAutoData("AD", "A123456Z", "ad_nrt")] - [BitAutoData("AR", "20-12345678-9", "ar_cuit")] - [BitAutoData("AR", "20123456789", "ar_cuit")] - [BitAutoData("AU", "01259983598", "au_abn")] - [BitAutoData("AU", "123456789123", "au_arn")] - [BitAutoData("AT", "ATU12345678", "eu_vat")] - [BitAutoData("BH", "123456789012345", "bh_vat")] - [BitAutoData("BY", "123456789", "by_tin")] - [BitAutoData("BE", "BE0123456789", "eu_vat")] - [BitAutoData("BO", "123456789", "bo_tin")] - [BitAutoData("BR", "01.234.456/5432-10", "br_cnpj")] - [BitAutoData("BR", "01234456543210", "br_cnpj")] - [BitAutoData("BR", "123.456.789-87", "br_cpf")] - [BitAutoData("BR", "12345678987", "br_cpf")] - [BitAutoData("BG", "123456789", "bg_uic")] - [BitAutoData("BG", "BG012100705", "eu_vat")] - [BitAutoData("CA", "100728494", "ca_bn")] - [BitAutoData("CA", "123456789RT0001", "ca_gst_hst")] - [BitAutoData("CA", "PST-1234-1234", "ca_pst_bc")] - [BitAutoData("CA", "123456-7", "ca_pst_mb")] - [BitAutoData("CA", "1234567", "ca_pst_sk")] - [BitAutoData("CA", "1234567890TQ1234", "ca_qst")] - [BitAutoData("CL", "11.121.326-1", "cl_tin")] - [BitAutoData("CL", "11121326-1", "cl_tin")] - [BitAutoData("CL", "23.121.326-K", "cl_tin")] - [BitAutoData("CL", "43651326-K", "cl_tin")] - [BitAutoData("CN", "123456789012345678", "cn_tin")] - [BitAutoData("CN", "123456789012345", "cn_tin")] - [BitAutoData("CO", "123.456.789-0", "co_nit")] - [BitAutoData("CO", "1234567890", "co_nit")] - [BitAutoData("CR", "1-234-567890", "cr_tin")] - [BitAutoData("CR", "1234567890", "cr_tin")] - [BitAutoData("HR", "HR12345678912", "eu_vat")] - [BitAutoData("HR", "12345678901", "hr_oib")] - [BitAutoData("CY", "CY12345678X", "eu_vat")] - [BitAutoData("CZ", "CZ12345678", "eu_vat")] - [BitAutoData("DK", "DK12345678", "eu_vat")] - [BitAutoData("DO", "123-4567890-1", "do_rcn")] - [BitAutoData("DO", "12345678901", "do_rcn")] - [BitAutoData("EC", "1234567890001", "ec_ruc")] - [BitAutoData("EG", "123456789", "eg_tin")] - [BitAutoData("SV", "1234-567890-123-4", "sv_nit")] - [BitAutoData("SV", "12345678901234", "sv_nit")] - [BitAutoData("EE", "EE123456789", "eu_vat")] - [BitAutoData("EU", "EU123456789", "eu_oss_vat")] - [BitAutoData("FI", "FI12345678", "eu_vat")] - [BitAutoData("FR", "FR12345678901", "eu_vat")] - [BitAutoData("GE", "123456789", "ge_vat")] - [BitAutoData("DE", "1234567890", "de_stn")] - [BitAutoData("DE", "DE123456789", "eu_vat")] - [BitAutoData("GR", "EL123456789", "eu_vat")] - [BitAutoData("HK", "12345678", "hk_br")] - [BitAutoData("HU", "HU12345678", "eu_vat")] - [BitAutoData("HU", "12345678-1-23", "hu_tin")] - [BitAutoData("HU", "12345678123", "hu_tin")] - [BitAutoData("IS", "123456", "is_vat")] - [BitAutoData("IN", "12ABCDE1234F1Z5", "in_gst")] - [BitAutoData("IN", "12ABCDE3456FGZH", "in_gst")] - [BitAutoData("ID", "012.345.678.9-012.345", "id_npwp")] - [BitAutoData("ID", "0123456789012345", "id_npwp")] - [BitAutoData("IE", "IE1234567A", "eu_vat")] - [BitAutoData("IE", "IE1234567AB", "eu_vat")] - [BitAutoData("IL", "000012345", "il_vat")] - [BitAutoData("IL", "123456789", "il_vat")] - [BitAutoData("IT", "IT12345678901", "eu_vat")] - [BitAutoData("JP", "1234567890123", "jp_cn")] - [BitAutoData("JP", "12345", "jp_rn")] - [BitAutoData("KZ", "123456789012", "kz_bin")] - [BitAutoData("KE", "P000111111A", "ke_pin")] - [BitAutoData("LV", "LV12345678912", "eu_vat")] - [BitAutoData("LI", "CHE123456789", "li_uid")] - [BitAutoData("LI", "12345", "li_vat")] - [BitAutoData("LT", "LT123456789123", "eu_vat")] - [BitAutoData("LU", "LU12345678", "eu_vat")] - [BitAutoData("MY", "12345678", "my_frp")] - [BitAutoData("MY", "C 1234567890", "my_itn")] - [BitAutoData("MY", "C1234567890", "my_itn")] - [BitAutoData("MY", "A12-3456-78912345", "my_sst")] - [BitAutoData("MY", "A12345678912345", "my_sst")] - [BitAutoData("MT", "MT12345678", "eu_vat")] - [BitAutoData("MX", "ABC010203AB9", "mx_rfc")] - [BitAutoData("MD", "1003600", "md_vat")] - [BitAutoData("MA", "12345678", "ma_vat")] - [BitAutoData("NL", "NL123456789B12", "eu_vat")] - [BitAutoData("NZ", "123456789", "nz_gst")] - [BitAutoData("NG", "12345678-0001", "ng_tin")] - [BitAutoData("NO", "123456789MVA", "no_vat")] - [BitAutoData("NO", "1234567", "no_voec")] - [BitAutoData("OM", "OM1234567890", "om_vat")] - [BitAutoData("PE", "12345678901", "pe_ruc")] - [BitAutoData("PH", "123456789012", "ph_tin")] - [BitAutoData("PL", "PL1234567890", "eu_vat")] - [BitAutoData("PT", "PT123456789", "eu_vat")] - [BitAutoData("RO", "RO1234567891", "eu_vat")] - [BitAutoData("RO", "1234567890123", "ro_tin")] - [BitAutoData("RU", "1234567891", "ru_inn")] - [BitAutoData("RU", "123456789", "ru_kpp")] - [BitAutoData("SA", "123456789012345", "sa_vat")] - [BitAutoData("RS", "123456789", "rs_pib")] - [BitAutoData("SG", "M12345678X", "sg_gst")] - [BitAutoData("SG", "123456789F", "sg_uen")] - [BitAutoData("SK", "SK1234567891", "eu_vat")] - [BitAutoData("SI", "SI12345678", "eu_vat")] - [BitAutoData("SI", "12345678", "si_tin")] - [BitAutoData("ZA", "4123456789", "za_vat")] - [BitAutoData("KR", "123-45-67890", "kr_brn")] - [BitAutoData("KR", "1234567890", "kr_brn")] - [BitAutoData("ES", "A12345678", "es_cif")] - [BitAutoData("ES", "ESX1234567X", "eu_vat")] - [BitAutoData("SE", "SE123456789012", "eu_vat")] - [BitAutoData("CH", "CHE-123.456.789 HR", "ch_uid")] - [BitAutoData("CH", "CHE123456789HR", "ch_uid")] - [BitAutoData("CH", "CHE-123.456.789 MWST", "ch_vat")] - [BitAutoData("CH", "CHE123456789MWST", "ch_vat")] - [BitAutoData("TW", "12345678", "tw_vat")] - [BitAutoData("TH", "1234567890123", "th_vat")] - [BitAutoData("TR", "0123456789", "tr_tin")] - [BitAutoData("UA", "123456789", "ua_vat")] - [BitAutoData("AE", "123456789012345", "ae_trn")] - [BitAutoData("GB", "XI123456789", "eu_vat")] - [BitAutoData("GB", "GB123456789", "gb_vat")] - [BitAutoData("US", "12-3456789", "us_ein")] - [BitAutoData("UY", "123456789012", "uy_ruc")] - [BitAutoData("UZ", "123456789", "uz_tin")] - [BitAutoData("UZ", "123456789012", "uz_vat")] - [BitAutoData("VE", "A-12345678-9", "ve_rif")] - [BitAutoData("VE", "A123456789", "ve_rif")] - [BitAutoData("VN", "1234567890", "vn_tin")] - public void GetStripeTaxCode_WithValidCountryAndTaxId_ReturnsExpectedTaxIdType( - string country, - string taxId, - string expected, - SutProvider sutProvider) - { - var result = sutProvider.Sut.GetStripeTaxCode(country, taxId); - - Assert.Equal(expected, result); - } -} diff --git a/src/Api/Billing/Controllers/AccountsBillingController.cs b/src/Api/Billing/Controllers/AccountsBillingController.cs index fcb89226e7..574ac3e65e 100644 --- a/src/Api/Billing/Controllers/AccountsBillingController.cs +++ b/src/Api/Billing/Controllers/AccountsBillingController.cs @@ -1,6 +1,5 @@ #nullable enable using Bit.Api.Billing.Models.Responses; -using Bit.Core.Billing.Models.Api.Requests.Accounts; using Bit.Core.Billing.Services; using Bit.Core.Services; using Bit.Core.Utilities; @@ -78,18 +77,4 @@ public class AccountsBillingController( return TypedResults.Ok(transactions); } - - [HttpPost("preview-invoice")] - public async Task PreviewInvoiceAsync([FromBody] PreviewIndividualInvoiceRequestBody model) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var invoice = await paymentService.PreviewInvoiceAsync(model, user.GatewayCustomerId, user.GatewaySubscriptionId); - - return TypedResults.Ok(invoice); - } } diff --git a/src/Api/Billing/Controllers/InvoicesController.cs b/src/Api/Billing/Controllers/InvoicesController.cs deleted file mode 100644 index 686d9b9643..0000000000 --- a/src/Api/Billing/Controllers/InvoicesController.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Models.Api.Requests.Organizations; -using Bit.Core.Context; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Billing.Controllers; - -[Route("invoices")] -[Authorize("Application")] -public class InvoicesController : BaseBillingController -{ - [HttpPost("preview-organization")] - public async Task PreviewInvoiceAsync( - [FromBody] PreviewOrganizationInvoiceRequestBody model, - [FromServices] ICurrentContext currentContext, - [FromServices] IOrganizationRepository organizationRepository, - [FromServices] IPaymentService paymentService) - { - Organization organization = null; - if (model.OrganizationId != default) - { - if (!await currentContext.EditPaymentMethods(model.OrganizationId)) - { - return Error.Unauthorized(); - } - - organization = await organizationRepository.GetByIdAsync(model.OrganizationId); - if (organization == null) - { - return Error.NotFound(); - } - } - - var invoice = await paymentService.PreviewInvoiceAsync(model, organization?.GatewayCustomerId, - organization?.GatewaySubscriptionId); - - return TypedResults.Ok(invoice); - } -} diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index c5de63c69b..f7ddf0853e 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -119,7 +119,6 @@ public class ProviderBillingController( requestBody.Country, requestBody.PostalCode, requestBody.TaxId, - requestBody.TaxIdType, requestBody.Line1, requestBody.Line2, requestBody.City, diff --git a/src/Api/Billing/Controllers/StripeController.cs b/src/Api/Billing/Controllers/StripeController.cs index f5e8253bfa..a4a974bb99 100644 --- a/src/Api/Billing/Controllers/StripeController.cs +++ b/src/Api/Billing/Controllers/StripeController.cs @@ -1,5 +1,4 @@ -using Bit.Core.Billing.Services; -using Bit.Core.Services; +using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -47,15 +46,4 @@ public class StripeController( return TypedResults.Ok(setupIntent.ClientSecret); } - - [HttpGet] - [Route("~/tax/is-country-supported")] - public IResult IsCountrySupported( - [FromQuery] string country, - [FromServices] ITaxService taxService) - { - var isSupported = taxService.IsSupported(country); - - return TypedResults.Ok(isSupported); - } } diff --git a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs index 32ba2effb2..c5c0fde00b 100644 --- a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs +++ b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs @@ -10,7 +10,6 @@ public class TaxInformationRequestBody [Required] public string PostalCode { get; set; } public string TaxId { get; set; } - public string TaxIdType { get; set; } public string Line1 { get; set; } public string Line2 { get; set; } public string City { get; set; } @@ -20,7 +19,6 @@ public class TaxInformationRequestBody Country, PostalCode, TaxId, - TaxIdType, Line1, Line2, City, diff --git a/src/Core/Billing/Extensions/CurrencyExtensions.cs b/src/Core/Billing/Extensions/CurrencyExtensions.cs deleted file mode 100644 index cde1a7bea8..0000000000 --- a/src/Core/Billing/Extensions/CurrencyExtensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Bit.Core.Billing.Extensions; - -public static class CurrencyExtensions -{ - /// - /// Converts a currency amount in major units to minor units. - /// - /// 123.99 USD returns 12399 in minor units. - public static long ToMinor(this decimal amount) - { - return Convert.ToInt64(amount * 100); - } - - /// - /// Converts a currency amount in minor units to major units. - /// - /// - /// 12399 in minor units returns 123.99 USD. - public static decimal? ToMajor(this long? amount) - { - return amount?.ToMajor(); - } - - /// - /// Converts a currency amount in minor units to major units. - /// - /// - /// 12399 in minor units returns 123.99 USD. - public static decimal ToMajor(this long amount) - { - return Convert.ToDecimal(amount) / 100; - } -} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 8f2803d920..abfceac736 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -11,7 +11,6 @@ public static class ServiceCollectionExtensions { public static void AddBillingOperations(this IServiceCollection services) { - services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs deleted file mode 100644 index 6dfb9894d5..0000000000 --- a/src/Core/Billing/Models/Api/Requests/Accounts/PreviewIndividualInvoiceRequestModel.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Bit.Core.Billing.Models.Api.Requests.Accounts; - -public class PreviewIndividualInvoiceRequestBody -{ - [Required] - public PasswordManagerRequestModel PasswordManager { get; set; } - - [Required] - public TaxInformationRequestModel TaxInformation { get; set; } -} - -public class PasswordManagerRequestModel -{ - [Range(0, int.MaxValue)] - public int AdditionalStorage { get; set; } -} diff --git a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs b/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs deleted file mode 100644 index 18d9c352d7..0000000000 --- a/src/Core/Billing/Models/Api/Requests/Organizations/PreviewOrganizationInvoiceRequestModel.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Bit.Core.Billing.Enums; - -namespace Bit.Core.Billing.Models.Api.Requests.Organizations; - -public class PreviewOrganizationInvoiceRequestBody -{ - public Guid OrganizationId { get; set; } - - [Required] - public PasswordManagerRequestModel PasswordManager { get; set; } - - public SecretsManagerRequestModel SecretsManager { get; set; } - - [Required] - public TaxInformationRequestModel TaxInformation { get; set; } -} - -public class PasswordManagerRequestModel -{ - public PlanType Plan { get; set; } - - [Range(0, int.MaxValue)] - public int Seats { get; set; } - - [Range(0, int.MaxValue)] - public int AdditionalStorage { get; set; } -} - -public class SecretsManagerRequestModel -{ - [Range(0, int.MaxValue)] - public int Seats { get; set; } - - [Range(0, int.MaxValue)] - public int AdditionalMachineAccounts { get; set; } -} diff --git a/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs b/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs deleted file mode 100644 index 9cb43645c6..0000000000 --- a/src/Core/Billing/Models/Api/Requests/TaxInformationRequestModel.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Bit.Core.Billing.Models.Api.Requests; - -public class TaxInformationRequestModel -{ - [Length(2, 2), Required] - public string Country { get; set; } - - [Required] - public string PostalCode { get; set; } - - public string TaxId { get; set; } -} diff --git a/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs b/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs deleted file mode 100644 index fdde7dae1e..0000000000 --- a/src/Core/Billing/Models/Api/Responses/PreviewInvoiceResponseModel.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bit.Core.Billing.Models.Api.Responses; - -public record PreviewInvoiceResponseModel( - decimal EffectiveTaxRate, - decimal TaxableBaseAmount, - decimal TaxAmount, - decimal TotalAmount); diff --git a/src/Core/Billing/Models/PreviewInvoiceInfo.cs b/src/Core/Billing/Models/PreviewInvoiceInfo.cs deleted file mode 100644 index 16a2019c20..0000000000 --- a/src/Core/Billing/Models/PreviewInvoiceInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bit.Core.Billing.Models; - -public record PreviewInvoiceInfo( - decimal EffectiveTaxRate, - decimal TaxableBaseAmount, - decimal TaxAmount, - decimal TotalAmount); diff --git a/src/Core/Billing/Models/Sales/OrganizationSale.cs b/src/Core/Billing/Models/Sales/OrganizationSale.cs index 43852bb320..a19c278c68 100644 --- a/src/Core/Billing/Models/Sales/OrganizationSale.cs +++ b/src/Core/Billing/Models/Sales/OrganizationSale.cs @@ -65,7 +65,6 @@ public class OrganizationSale signup.TaxInfo.BillingAddressCountry, signup.TaxInfo.BillingAddressPostalCode, signup.TaxInfo.TaxIdNumber, - signup.TaxInfo.TaxIdType, signup.TaxInfo.BillingAddressLine1, signup.TaxInfo.BillingAddressLine2, signup.TaxInfo.BillingAddressCity, diff --git a/src/Core/Billing/Models/TaxIdType.cs b/src/Core/Billing/Models/TaxIdType.cs deleted file mode 100644 index 3fc246d68b..0000000000 --- a/src/Core/Billing/Models/TaxIdType.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.RegularExpressions; - -namespace Bit.Core.Billing.Models; - -public class TaxIdType -{ - /// - /// ISO-3166-2 code for the country. - /// - public string Country { get; set; } - - /// - /// The identifier in Stripe for the tax ID type. - /// - public string Code { get; set; } - - public Regex ValidationExpression { get; set; } - - public string Description { get; set; } - - public string Example { get; set; } -} diff --git a/src/Core/Billing/Models/TaxInformation.cs b/src/Core/Billing/Models/TaxInformation.cs index 23ed3e5faa..5403f94690 100644 --- a/src/Core/Billing/Models/TaxInformation.cs +++ b/src/Core/Billing/Models/TaxInformation.cs @@ -1,4 +1,5 @@ using Bit.Core.Models.Business; +using Stripe; namespace Bit.Core.Billing.Models; @@ -6,7 +7,6 @@ public record TaxInformation( string Country, string PostalCode, string TaxId, - string TaxIdType, string Line1, string Line2, string City, @@ -16,9 +16,165 @@ public record TaxInformation( taxInfo.BillingAddressCountry, taxInfo.BillingAddressPostalCode, taxInfo.TaxIdNumber, - taxInfo.TaxIdType, taxInfo.BillingAddressLine1, taxInfo.BillingAddressLine2, taxInfo.BillingAddressCity, taxInfo.BillingAddressState); + + public (AddressOptions, List) GetStripeOptions() + { + var address = new AddressOptions + { + Country = Country, + PostalCode = PostalCode, + Line1 = Line1, + Line2 = Line2, + City = City, + State = State + }; + + var customerTaxIdDataOptionsList = !string.IsNullOrEmpty(TaxId) + ? new List { new() { Type = GetTaxIdType(), Value = TaxId } } + : null; + + return (address, customerTaxIdDataOptionsList); + } + + public string GetTaxIdType() + { + if (string.IsNullOrEmpty(Country) || string.IsNullOrEmpty(TaxId)) + { + return null; + } + + switch (Country.ToUpper()) + { + case "AD": + return "ad_nrt"; + case "AE": + return "ae_trn"; + case "AR": + return "ar_cuit"; + case "AU": + return "au_abn"; + case "BO": + return "bo_tin"; + case "BR": + return "br_cnpj"; + case "CA": + // May break for those in Québec given the assumption of QST + if (State?.Contains("bec") ?? false) + { + return "ca_qst"; + } + return "ca_bn"; + case "CH": + return "ch_vat"; + case "CL": + return "cl_tin"; + case "CN": + return "cn_tin"; + case "CO": + return "co_nit"; + case "CR": + return "cr_tin"; + case "DO": + return "do_rcn"; + case "EC": + return "ec_ruc"; + case "EG": + return "eg_tin"; + case "GE": + return "ge_vat"; + case "ID": + return "id_npwp"; + case "IL": + return "il_vat"; + case "IS": + return "is_vat"; + case "KE": + return "ke_pin"; + case "AT": + case "BE": + case "BG": + case "CY": + case "CZ": + case "DE": + case "DK": + case "EE": + case "ES": + case "FI": + case "FR": + case "GB": + case "GR": + case "HR": + case "HU": + case "IE": + case "IT": + case "LT": + case "LU": + case "LV": + case "MT": + case "NL": + case "PL": + case "PT": + case "RO": + case "SE": + case "SI": + case "SK": + return "eu_vat"; + case "HK": + return "hk_br"; + case "IN": + return "in_gst"; + case "JP": + return "jp_cn"; + case "KR": + return "kr_brn"; + case "LI": + return "li_uid"; + case "MX": + return "mx_rfc"; + case "MY": + return "my_sst"; + case "NO": + return "no_vat"; + case "NZ": + return "nz_gst"; + case "PE": + return "pe_ruc"; + case "PH": + return "ph_tin"; + case "RS": + return "rs_pib"; + case "RU": + return "ru_inn"; + case "SA": + return "sa_vat"; + case "SG": + return "sg_gst"; + case "SV": + return "sv_nit"; + case "TH": + return "th_vat"; + case "TR": + return "tr_tin"; + case "TW": + return "tw_vat"; + case "UA": + return "ua_vat"; + case "US": + return "us_ein"; + case "UY": + return "uy_ruc"; + case "VE": + return "ve_rif"; + case "VN": + return "vn_tin"; + case "ZA": + return "za_vat"; + default: + return null; + } + } } diff --git a/src/Core/Billing/Services/ITaxService.cs b/src/Core/Billing/Services/ITaxService.cs deleted file mode 100644 index beee113d17..0000000000 --- a/src/Core/Billing/Services/ITaxService.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Bit.Core.Billing.Services; - -public interface ITaxService -{ - /// - /// Retrieves the Stripe tax code for a given country and tax ID. - /// - /// - /// - /// - /// Returns the Stripe tax code if the tax ID is valid for the country. - /// Returns null if the tax ID is invalid or the country is not supported. - /// - string GetStripeTaxCode(string country, string taxId); - - /// - /// Returns true or false whether charging or storing tax is supported for the given country. - /// - /// - /// - bool IsSupported(string country); -} diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index b186a99d93..eadc589625 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -28,8 +28,7 @@ public class OrganizationBillingService( IOrganizationRepository organizationRepository, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, - ISubscriberService subscriberService, - ITaxService taxService) : IOrganizationBillingService + ISubscriberService subscriberService) : IOrganizationBillingService { public async Task Finalize(OrganizationSale sale) { @@ -168,38 +167,14 @@ public class OrganizationBillingService( throw new BillingException(); } - customerCreateOptions.Address = new AddressOptions - { - Line1 = customerSetup.TaxInformation.Line1, - Line2 = customerSetup.TaxInformation.Line2, - City = customerSetup.TaxInformation.City, - PostalCode = customerSetup.TaxInformation.PostalCode, - State = customerSetup.TaxInformation.State, - Country = customerSetup.TaxInformation.Country, - }; + var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions(); + + customerCreateOptions.Address = address; customerCreateOptions.Tax = new CustomerTaxOptions { ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately }; - - if (!string.IsNullOrEmpty(customerSetup.TaxInformation.TaxId)) - { - var taxIdType = taxService.GetStripeTaxCode(customerSetup.TaxInformation.Country, - customerSetup.TaxInformation.TaxId); - - if (taxIdType == null) - { - logger.LogWarning("Could not determine tax ID type for organization '{OrganizationID}' in country '{Country}' with tax ID '{TaxID}'.", - organization.Id, - customerSetup.TaxInformation.Country, - customerSetup.TaxInformation.TaxId); - } - - customerCreateOptions.TaxIdData = - [ - new() { Type = taxIdType, Value = customerSetup.TaxInformation.TaxId } - ]; - } + customerCreateOptions.TaxIdData = taxIdData; var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource; diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 5351888ad9..92c81dae1c 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -24,8 +24,7 @@ public class PremiumUserBillingService( ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, - IUserRepository userRepository, - ITaxService taxService) : IPremiumUserBillingService + IUserRepository userRepository) : IPremiumUserBillingService { public async Task Finalize(PremiumUserSale sale) { @@ -83,19 +82,13 @@ public class PremiumUserBillingService( throw new BillingException(); } + var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions(); + var subscriberName = user.SubscriberName(); var customerCreateOptions = new CustomerCreateOptions { - Address = new AddressOptions - { - Line1 = customerSetup.TaxInformation.Line1, - Line2 = customerSetup.TaxInformation.Line2, - City = customerSetup.TaxInformation.City, - PostalCode = customerSetup.TaxInformation.PostalCode, - State = customerSetup.TaxInformation.State, - Country = customerSetup.TaxInformation.Country, - }, + Address = address, Description = user.Name, Email = user.Email, Expand = ["tax"], @@ -120,28 +113,10 @@ public class PremiumUserBillingService( Tax = new CustomerTaxOptions { ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately - } + }, + TaxIdData = taxIdData }; - if (!string.IsNullOrEmpty(customerSetup.TaxInformation.TaxId)) - { - var taxIdType = taxService.GetStripeTaxCode(customerSetup.TaxInformation.Country, - customerSetup.TaxInformation.TaxId); - - if (taxIdType == null) - { - logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", - customerSetup.TaxInformation.Country, - customerSetup.TaxInformation.TaxId); - throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError"); - } - - customerCreateOptions.TaxIdData = - [ - new() { Type = taxIdType, Value = customerSetup.TaxInformation.TaxId } - ]; - } - var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource; var braintreeCustomerId = ""; diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 6125d15419..9b8f64be82 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -23,8 +23,7 @@ public class SubscriberService( IGlobalSettings globalSettings, ILogger logger, ISetupIntentCache setupIntentCache, - IStripeAdapter stripeAdapter, - ITaxService taxService) : ISubscriberService + IStripeAdapter stripeAdapter) : ISubscriberService { public async Task CancelSubscription( ISubscriber subscriber, @@ -619,47 +618,16 @@ public class SubscriberService( await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); } - if (string.IsNullOrWhiteSpace(taxInformation.TaxId)) - { - return; - } + var taxIdType = taxInformation.GetTaxIdType(); - var taxIdType = taxInformation.TaxIdType; - if (string.IsNullOrWhiteSpace(taxIdType)) + if (!string.IsNullOrWhiteSpace(taxInformation.TaxId) && + !string.IsNullOrWhiteSpace(taxIdType)) { - taxIdType = taxService.GetStripeTaxCode(taxInformation.Country, - taxInformation.TaxId); - - if (taxIdType == null) + await stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions { - logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", - taxInformation.Country, - taxInformation.TaxId); - throw new Exceptions.BadRequestException("billingTaxIdTypeInferenceError"); - } - } - - try - { - await stripeAdapter.TaxIdCreateAsync(customer.Id, - new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId }); - } - catch (StripeException e) - { - switch (e.StripeError.Code) - { - case StripeConstants.ErrorCodes.TaxIdInvalid: - logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - taxInformation.TaxId, - taxInformation.Country); - throw new Exceptions.BadRequestException("billingInvalidTaxIdError"); - default: - logger.LogError(e, "Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.", - taxInformation.TaxId, - taxInformation.Country, - customer.Id); - throw new Exceptions.BadRequestException("billingTaxIdCreationError"); - } + Type = taxIdType, + Value = taxInformation.TaxId, + }); } } @@ -802,7 +770,6 @@ public class SubscriberService( customer.Address.Country, customer.Address.PostalCode, customer.TaxIds?.FirstOrDefault()?.Value, - customer.TaxIds?.FirstOrDefault()?.Type, customer.Address.Line1, customer.Address.Line2, customer.Address.City, diff --git a/src/Core/Billing/Services/TaxService.cs b/src/Core/Billing/Services/TaxService.cs deleted file mode 100644 index 3066be92d1..0000000000 --- a/src/Core/Billing/Services/TaxService.cs +++ /dev/null @@ -1,901 +0,0 @@ -using System.Text.RegularExpressions; -using Bit.Core.Billing.Models; - -namespace Bit.Core.Billing.Services; - -public class TaxService : ITaxService -{ - /// - /// Retrieves a list of supported tax ID types for customers. - /// - /// Compiled list from Stripe - private static readonly IEnumerable _taxIdTypes = - [ - new() - { - Country = "AD", - Code = "ad_nrt", - Description = "Andorran NRT number", - Example = "A-123456-Z", - ValidationExpression = new Regex("^([A-Z]{1})-?([0-9]{6})-?([A-Z]{1})$") - }, - new() - { - Country = "AR", - Code = "ar_cuit", - Description = "Argentinian tax ID number", - Example = "12-34567890-1", - ValidationExpression = new Regex("^([0-9]{2})-?([0-9]{8})-?([0-9]{1})$") - }, - new() - { - Country = "AU", - Code = "au_abn", - Description = "Australian Business Number (AU ABN)", - Example = "123456789012", - ValidationExpression = new Regex("^[0-9]{11}$") - }, - new() - { - Country = "AU", - Code = "au_arn", - Description = "Australian Taxation Office Reference Number", - Example = "123456789123", - ValidationExpression = new Regex("^[0-9]{12}$") - }, - new() - { - Country = "AT", - Code = "eu_vat", - Description = "European VAT number (Austria)", - Example = "ATU12345678", - ValidationExpression = new Regex("^ATU[0-9]{8}$") - }, - new() - { - Country = "BH", - Code = "bh_vat", - Description = "Bahraini VAT Number", - Example = "123456789012345", - ValidationExpression = new Regex("^[0-9]{15}$") - }, - new() - { - Country = "BY", - Code = "by_tin", - Description = "Belarus TIN Number", - Example = "123456789", - ValidationExpression = new Regex("^[0-9]{9}$") - }, - new() - { - Country = "BE", - Code = "eu_vat", - Description = "European VAT number (Belgium)", - Example = "BE0123456789", - ValidationExpression = new Regex("^BE[0-9]{10}$") - }, - new() - { - Country = "BO", - Code = "bo_tin", - Description = "Bolivian tax ID", - Example = "123456789", - ValidationExpression = new Regex("^[0-9]{9}$") - }, - new() - { - Country = "BR", - Code = "br_cnpj", - Description = "Brazilian CNPJ number", - Example = "01.234.456/5432-10", - ValidationExpression = new Regex("^[0-9]{2}.?[0-9]{3}.?[0-9]{3}/?[0-9]{4}-?[0-9]{2}$") - }, - new() - { - Country = "BR", - Code = "br_cpf", - Description = "Brazilian CPF number", - Example = "123.456.789-87", - ValidationExpression = new Regex("^[0-9]{3}.?[0-9]{3}.?[0-9]{3}-?[0-9]{2}$") - }, - new() - { - Country = "BG", - Code = "bg_uic", - Description = "Bulgaria Unified Identification Code", - Example = "123456789", - ValidationExpression = new Regex("^[0-9]{9}$") - }, - new() - { - Country = "BG", - Code = "eu_vat", - Description = "European VAT number (Bulgaria)", - Example = "BG0123456789", - ValidationExpression = new Regex("^BG[0-9]{9,10}$") - }, - new() - { - Country = "CA", - Code = "ca_bn", - Description = "Canadian BN", - Example = "123456789", - ValidationExpression = new Regex("^[0-9]{9}$") - }, - new() - { - Country = "CA", - Code = "ca_gst_hst", - Description = "Canadian GST/HST number", - Example = "123456789RT0002", - ValidationExpression = new Regex("^[0-9]{9}RT[0-9]{4}$") - }, - new() - { - Country = "CA", - Code = "ca_pst_bc", - Description = "Canadian PST number (British Columbia)", - Example = "PST-1234-5678", - ValidationExpression = new Regex("^PST-[0-9]{4}-[0-9]{4}$") - }, - new() - { - Country = "CA", - Code = "ca_pst_mb", - Description = "Canadian PST number (Manitoba)", - Example = "123456-7", - ValidationExpression = new Regex("^[0-9]{6}-[0-9]{1}$") - }, - new() - { - Country = "CA", - Code = "ca_pst_sk", - Description = "Canadian PST number (Saskatchewan)", - Example = "1234567", - ValidationExpression = new Regex("^[0-9]{7}$") - }, - new() - { - Country = "CA", - Code = "ca_qst", - Description = "Canadian QST number (Québec)", - Example = "1234567890TQ1234", - ValidationExpression = new Regex("^[0-9]{10}TQ[0-9]{4}$") - }, - new() - { - Country = "CL", - Code = "cl_tin", - Description = "Chilean TIN", - Example = "12.345.678-K", - ValidationExpression = new Regex("^[0-9]{2}.?[0-9]{3}.?[0-9]{3}-?[0-9A-Z]{1}$") - }, - new() - { - Country = "CN", - Code = "cn_tin", - Description = "Chinese tax ID", - Example = "123456789012345678", - ValidationExpression = new Regex("^[0-9]{15,18}$") - }, - new() - { - Country = "CO", - Code = "co_nit", - Description = "Colombian NIT number", - Example = "123.456.789-0", - ValidationExpression = new Regex("^[0-9]{3}.?[0-9]{3}.?[0-9]{3}-?[0-9]{1}$") - }, - new() - { - Country = "CR", - Code = "cr_tin", - Description = "Costa Rican tax ID", - Example = "1-234-567890", - ValidationExpression = new Regex("^[0-9]{1}-?[0-9]{3}-?[0-9]{6}$") - }, - new() - { - Country = "HR", - Code = "eu_vat", - Description = "European VAT number (Croatia)", - Example = "HR12345678912", - ValidationExpression = new Regex("^HR[0-9]{11}$") - }, - new() - { - Country = "HR", - Code = "hr_oib", - Description = "Croatian Personal Identification Number", - Example = "12345678901", - ValidationExpression = new Regex("^[0-9]{11}$") - }, - new() - { - Country = "CY", - Code = "eu_vat", - Description = "European VAT number (Cyprus)", - Example = "CY12345678X", - ValidationExpression = new Regex("^CY[0-9]{8}[A-Z]{1}$") - }, - new() - { - Country = "CZ", - Code = "eu_vat", - Description = "European VAT number (Czech Republic)", - Example = "CZ12345678", - ValidationExpression = new Regex("^CZ[0-9]{8,10}$") - }, - new() - { - Country = "DK", - Code = "eu_vat", - Description = "European VAT number (Denmark)", - Example = "DK12345678", - ValidationExpression = new Regex("^DK[0-9]{8}$") - }, - new() - { - Country = "DO", - Code = "do_rcn", - Description = "Dominican RCN number", - Example = "123-4567890-1", - ValidationExpression = new Regex("^[0-9]{3}-?[0-9]{7}-?[0-9]{1}$") - }, - new() - { - Country = "EC", - Code = "ec_ruc", - Description = "Ecuadorian RUC number", - Example = "1234567890001", - ValidationExpression = new Regex("^[0-9]{13}$") - }, - new() - { - Country = "EG", - Code = "eg_tin", - Description = "Egyptian Tax Identification Number", - Example = "123456789", - ValidationExpression = new Regex("^[0-9]{9}$") - }, - - new() - { - Country = "SV", - Code = "sv_nit", - Description = "El Salvadorian NIT number", - Example = "1234-567890-123-4", - ValidationExpression = new Regex("^[0-9]{4}-?[0-9]{6}-?[0-9]{3}-?[0-9]{1}$") - }, - - new() - { - Country = "EE", - Code = "eu_vat", - Description = "European VAT number (Estonia)", - Example = "EE123456789", - ValidationExpression = new Regex("^EE[0-9]{9}$") - }, - - new() - { - Country = "EU", - Code = "eu_oss_vat", - Description = "European One Stop Shop VAT number for non-Union scheme", - Example = "EU123456789", - ValidationExpression = new Regex("^EU[0-9]{9}$") - }, - new() - { - Country = "FI", - Code = "eu_vat", - Description = "European VAT number (Finland)", - Example = "FI12345678", - ValidationExpression = new Regex("^FI[0-9]{8}$") - }, - new() - { - Country = "FR", - Code = "eu_vat", - Description = "European VAT number (France)", - Example = "FR12345678901", - ValidationExpression = new Regex("^FR[0-9A-Z]{2}[0-9]{9}$") - }, - new() - { - Country = "GE", - Code = "ge_vat", - Description = "Georgian VAT", - Example = "123456789", - ValidationExpression = new Regex("^[0-9]{9}$") - }, - new() - { - Country = "DE", - Code = "de_stn", - Description = "German Tax Number (Steuernummer)", - Example = "1234567890", - ValidationExpression = new Regex("^[0-9]{10}$") - }, - new() - { - Country = "DE", - Code = "eu_vat", - Description = "European VAT number (Germany)", - Example = "DE123456789", - ValidationExpression = new Regex("^DE[0-9]{9}$") - }, - new() - { - Country = "GR", - Code = "eu_vat", - Description = "European VAT number (Greece)", - Example = "EL123456789", - ValidationExpression = new Regex("^EL[0-9]{9}$") - }, - new() - { - Country = "HK", - Code = "hk_br", - Description = "Hong Kong BR number", - Example = "12345678", - ValidationExpression = new Regex("^[0-9]{8}$") - }, - new() - { - Country = "HU", - Code = "eu_vat", - Description = "European VAT number (Hungaria)", - Example = "HU12345678", - ValidationExpression = new Regex("^HU[0-9]{8}$") - }, - new() - { - Country = "HU", - Code = "hu_tin", - Description = "Hungary tax number (adószám)", - Example = "12345678-1-23", - ValidationExpression = new Regex("^[0-9]{8}-?[0-9]-?[0-9]{2}$") - }, - new() - { - Country = "IS", - Code = "is_vat", - Description = "Icelandic VAT", - Example = "123456", - ValidationExpression = new Regex("^[0-9]{6}$") - }, - new() - { - Country = "IN", - Code = "in_gst", - Description = "Indian GST number", - Example = "12ABCDE3456FGZH", - ValidationExpression = new Regex("^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$") - }, - new() - { - Country = "ID", - Code = "id_npwp", - Description = "Indonesian NPWP number", - Example = "012.345.678.9-012.345", - ValidationExpression = new Regex("^[0-9]{3}.?[0-9]{3}.?[0-9]{3}.?[0-9]{1}-?[0-9]{3}.?[0-9]{3}$") - }, - new() - { - Country = "IE", - Code = "eu_vat", - Description = "European VAT number (Ireland)", - Example = "IE1234567AB", - ValidationExpression = new Regex("^IE[0-9]{7}[A-Z]{1,2}$") - }, - new() - { - Country = "IL", - Code = "il_vat", - Description = "Israel VAT", - Example = "000012345", - ValidationExpression = new Regex("^[0-9]{9}$") - }, - new() - { - Country = "IT", - Code = "eu_vat", - Description = "European VAT number (Italy)", - Example = "IT12345678912", - ValidationExpression = new Regex("^IT[0-9]{11}$") - }, - new() - { - Country = "JP", - Code = "jp_cn", - Description = "Japanese Corporate Number (*Hōjin Bangō*)", - Example = "1234567891234", - ValidationExpression = new Regex("^[0-9]{13}$") - }, - new() - { - Country = "JP", - Code = "jp_rn", - Description = - "Japanese Registered Foreign Businesses' Registration Number (*Tōroku Kokugai Jigyōsha no Tōroku Bangō*)", - Example = "12345", - ValidationExpression = new Regex("^[0-9]{5}$") - }, - new() - { - Country = "JP", - Code = "jp_trn", - Description = "Japanese Tax Registration Number (*Tōroku Bangō*)", - Example = "T1234567891234", - ValidationExpression = new Regex("^T[0-9]{13}$") - }, - new() - { - Country = "KZ", - Code = "kz_bin", - Description = "Kazakhstani Business Identification Number", - Example = "123456789012", - ValidationExpression = new Regex("^[0-9]{12}$") - }, - new() - { - Country = "KE", - Code = "ke_pin", - Description = "Kenya Revenue Authority Personal Identification Number", - Example = "P000111111A", - ValidationExpression = new Regex("^[A-Z]{1}[0-9]{9}[A-Z]{1}$") - }, - new() - { - Country = "LV", - Code = "eu_vat", - Description = "European VAT number", - Example = "LV12345678912", - ValidationExpression = new Regex("^LV[0-9]{11}$") - }, - new() - { - Country = "LI", - Code = "li_uid", - Description = "Liechtensteinian UID number", - Example = "CHE123456789", - ValidationExpression = new Regex("^CHE[0-9]{9}$") - }, - new() - { - Country = "LI", - Code = "li_vat", - Description = "Liechtensteinian VAT number", - Example = "12345", - ValidationExpression = new Regex("^[0-9]{5}$") - }, - new() - { - Country = "LT", - Code = "eu_vat", - Description = "European VAT number (Lithuania)", - Example = "LT123456789123", - ValidationExpression = new Regex("^LT[0-9]{9,12}$") - }, - new() - { - Country = "LU", - Code = "eu_vat", - Description = "European VAT number (Luxembourg)", - Example = "LU12345678", - ValidationExpression = new Regex("^LU[0-9]{8}$") - }, - new() - { - Country = "MY", - Code = "my_frp", - Description = "Malaysian FRP number", - Example = "12345678", - ValidationExpression = new Regex("^[0-9]{8}$") - }, - new() - { - Country = "MY", - Code = "my_itn", - Description = "Malaysian ITN", - Example = "C 1234567890", - ValidationExpression = new Regex("^[A-Z]{1} ?[0-9]{10}$") - }, - new() - { - Country = "MY", - Code = "my_sst", - Description = "Malaysian SST number", - Example = "A12-3456-78912345", - ValidationExpression = new Regex("^[A-Z]{1}[0-9]{2}-?[0-9]{4}-?[0-9]{8}$") - }, - new() - { - Country = "MT", - Code = "eu_vat", - Description = "European VAT number (Malta)", - Example = "MT12345678", - ValidationExpression = new Regex("^MT[0-9]{8}$") - }, - new() - { - Country = "MX", - Code = "mx_rfc", - Description = "Mexican RFC number", - Example = "ABC010203AB9", - ValidationExpression = new Regex("^[A-Z]{3}[0-9]{6}[A-Z0-9]{3}$") - }, - new() - { - Country = "MD", - Code = "md_vat", - Description = "Moldova VAT Number", - Example = "1234567", - ValidationExpression = new Regex("^[0-9]{7}$") - }, - new() - { - Country = "MA", - Code = "ma_vat", - Description = "Morocco VAT Number", - Example = "12345678", - ValidationExpression = new Regex("^[0-9]{8}$") - }, - new() - { - Country = "NL", - Code = "eu_vat", - Description = "European VAT number (Netherlands)", - Example = "NL123456789B12", - ValidationExpression = new Regex("^NL[0-9]{9}B[0-9]{2}$") - }, - new() - { - Country = "NZ", - Code = "nz_gst", - Description = "New Zealand GST number", - Example = "123456789", - ValidationExpression = new Regex("^[0-9]{9}$") - }, - new() - { - Country = "NG", - Code = "ng_tin", - Description = "Nigerian TIN Number", - Example = "12345678-0001", - ValidationExpression = new Regex("^[0-9]{8}-[0-9]{4}$") - }, - new() - { - Country = "NO", - Code = "no_vat", - Description = "Norwegian VAT number", - Example = "123456789MVA", - ValidationExpression = new Regex("^[0-9]{9}MVA$") - }, - new() - { - Country = "NO", - Code = "no_voec", - Description = "Norwegian VAT on e-commerce number", - Example = "1234567", - ValidationExpression = new Regex("^[0-9]{7}$") - }, - new() - { - Country = "OM", - Code = "om_vat", - Description = "Omani VAT Number", - Example = "OM1234567890", - ValidationExpression = new Regex("^OM[0-9]{10}$") - }, - new() - { - Country = "PE", - Code = "pe_ruc", - Description = "Peruvian RUC number", - Example = "12345678901", - ValidationExpression = new Regex("^[0-9]{11}$") - }, - new() - { - Country = "PH", - Code = "ph_tin", - Description = "Philippines Tax Identification Number", - Example = "123456789012", - ValidationExpression = new Regex("^[0-9]{12}$") - }, - new() - { - Country = "PL", - Code = "eu_vat", - Description = "European VAT number (Poland)", - Example = "PL1234567890", - ValidationExpression = new Regex("^PL[0-9]{10}$") - }, - new() - { - Country = "PT", - Code = "eu_vat", - Description = "European VAT number (Portugal)", - Example = "PT123456789", - ValidationExpression = new Regex("^PT[0-9]{9}$") - }, - new() - { - Country = "RO", - Code = "eu_vat", - Description = "European VAT number (Romania)", - Example = "RO1234567891", - ValidationExpression = new Regex("^RO[0-9]{2,10}$") - }, - new() - { - Country = "RO", - Code = "ro_tin", - Description = "Romanian tax ID number", - Example = "1234567890123", - ValidationExpression = new Regex("^[0-9]{13}$") - }, - new() - { - Country = "RU", - Code = "ru_inn", - Description = "Russian INN", - Example = "1234567891", - ValidationExpression = new Regex("^[0-9]{10,12}$") - }, - new() - { - Country = "RU", - Code = "ru_kpp", - Description = "Russian KPP", - Example = "123456789", - ValidationExpression = new Regex("^[0-9]{9}$") - }, - new() - { - Country = "SA", - Code = "sa_vat", - Description = "Saudi Arabia VAT", - Example = "123456789012345", - ValidationExpression = new Regex("^[0-9]{15}$") - }, - new() - { - Country = "RS", - Code = "rs_pib", - Description = "Serbian PIB number", - Example = "123456789", - ValidationExpression = new Regex("^[0-9]{9}$") - }, - new() - { - Country = "SG", - Code = "sg_gst", - Description = "Singaporean GST", - Example = "M12345678X", - ValidationExpression = new Regex("^[A-Z]{1}[0-9]{8}[A-Z]{1}$") - }, - new() - { - Country = "SG", - Code = "sg_uen", - Description = "Singaporean UEN", - Example = "123456789F", - ValidationExpression = new Regex("^[0-9]{9}[A-Z]{1}$") - }, - new() - { - Country = "SK", - Code = "eu_vat", - Description = "European VAT number (Slovakia)", - Example = "SK1234567891", - ValidationExpression = new Regex("^SK[0-9]{10}$") - }, - new() - { - Country = "SI", - Code = "eu_vat", - Description = "European VAT number (Slovenia)", - Example = "SI12345678", - ValidationExpression = new Regex("^SI[0-9]{8}$") - }, - new() - { - Country = "SI", - Code = "si_tin", - Description = "Slovenia tax number (davčna številka)", - Example = "12345678", - ValidationExpression = new Regex("^[0-9]{8}$") - }, - new() - { - Country = "ZA", - Code = "za_vat", - Description = "South African VAT number", - Example = "4123456789", - ValidationExpression = new Regex("^[0-9]{10}$") - }, - new() - { - Country = "KR", - Code = "kr_brn", - Description = "Korean BRN", - Example = "123-45-67890", - ValidationExpression = new Regex("^[0-9]{3}-?[0-9]{2}-?[0-9]{5}$") - }, - new() - { - Country = "ES", - Code = "es_cif", - Description = "Spanish NIF/CIF number", - Example = "A12345678", - ValidationExpression = new Regex("^[A-Z]{1}[0-9]{8}$") - }, - new() - { - Country = "ES", - Code = "eu_vat", - Description = "European VAT number (Spain)", - Example = "ESA1234567Z", - ValidationExpression = new Regex("^ES[A-Z]{1}[0-9]{7}[A-Z]{1}$") - }, - new() - { - Country = "SE", - Code = "eu_vat", - Description = "European VAT number (Sweden)", - Example = "SE123456789123", - ValidationExpression = new Regex("^SE[0-9]{12}$") - }, - new() - { - Country = "CH", - Code = "ch_uid", - Description = "Switzerland UID number", - Example = "CHE-123.456.789 HR", - ValidationExpression = new Regex("^CHE-?[0-9]{3}.?[0-9]{3}.?[0-9]{3} ?HR$") - }, - new() - { - Country = "CH", - Code = "ch_vat", - Description = "Switzerland VAT number", - Example = "CHE-123.456.789 MWST", - ValidationExpression = new Regex("^CHE-?[0-9]{3}.?[0-9]{3}.?[0-9]{3} ?MWST$") - }, - new() - { - Country = "TW", - Code = "tw_vat", - Description = "Taiwanese VAT", - Example = "12345678", - ValidationExpression = new Regex("^[0-9]{8}$") - }, - new() - { - Country = "TZ", - Code = "tz_vat", - Description = "Tanzania VAT Number", - Example = "12345678A", - ValidationExpression = new Regex("^[0-9]{8}[A-Z]{1}$") - }, - new() - { - Country = "TH", - Code = "th_vat", - Description = "Thai VAT", - Example = "1234567891234", - ValidationExpression = new Regex("^[0-9]{13}$") - }, - new() - { - Country = "TR", - Code = "tr_tin", - Description = "Turkish TIN Number", - Example = "0123456789", - ValidationExpression = new Regex("^[0-9]{10}$") - }, - new() - { - Country = "UA", - Code = "ua_vat", - Description = "Ukrainian VAT", - Example = "123456789", - ValidationExpression = new Regex("^[0-9]{9}$") - }, - new() - { - Country = "AE", - Code = "ae_trn", - Description = "United Arab Emirates TRN", - Example = "123456789012345", - ValidationExpression = new Regex("^[0-9]{15}$") - }, - new() - { - Country = "GB", - Code = "eu_vat", - Description = "Northern Ireland VAT number", - Example = "XI123456789", - ValidationExpression = new Regex("^XI[0-9]{9}$") - }, - new() - { - Country = "GB", - Code = "gb_vat", - Description = "United Kingdom VAT number", - Example = "GB123456789", - ValidationExpression = new Regex("^GB[0-9]{9}$") - }, - new() - { - Country = "US", - Code = "us_ein", - Description = "United States EIN", - Example = "12-3456789", - ValidationExpression = new Regex("^[0-9]{2}-?[0-9]{7}$") - }, - new() - { - Country = "UY", - Code = "uy_ruc", - Description = "Uruguayan RUC number", - Example = "123456789012", - ValidationExpression = new Regex("^[0-9]{12}$") - }, - new() - { - Country = "UZ", - Code = "uz_tin", - Description = "Uzbekistan TIN Number", - Example = "123456789", - ValidationExpression = new Regex("^[0-9]{9}$") - }, - new() - { - Country = "UZ", - Code = "uz_vat", - Description = "Uzbekistan VAT Number", - Example = "123456789012", - ValidationExpression = new Regex("^[0-9]{12}$") - }, - new() - { - Country = "VE", - Code = "ve_rif", - Description = "Venezuelan RIF number", - Example = "A-12345678-9", - ValidationExpression = new Regex("^[A-Z]{1}-?[0-9]{8}-?[0-9]{1}$") - }, - new() - { - Country = "VN", - Code = "vn_tin", - Description = "Vietnamese tax ID number", - Example = "1234567890", - ValidationExpression = new Regex("^[0-9]{10}$") - } - ]; - - public string GetStripeTaxCode(string country, string taxId) - { - foreach (var taxIdType in _taxIdTypes.Where(x => x.Country == country)) - { - if (taxIdType.ValidationExpression.IsMatch(taxId)) - { - return taxIdType.Code; - } - } - - return null; - } - - public bool IsSupported(string country) - { - return _taxIdTypes.Any(x => x.Country == country); - } -} diff --git a/src/Core/Billing/Utilities.cs b/src/Core/Billing/Utilities.cs index 695a3b1bb4..28527af0c0 100644 --- a/src/Core/Billing/Utilities.cs +++ b/src/Core/Billing/Utilities.cs @@ -83,7 +83,6 @@ public static class Utilities customer.Address.Country, customer.Address.PostalCode, customer.TaxIds?.FirstOrDefault()?.Value, - customer.TaxIds?.FirstOrDefault()?.Type, customer.Address.Line1, customer.Address.Line2, customer.Address.City, diff --git a/src/Core/Models/Business/TaxInfo.cs b/src/Core/Models/Business/TaxInfo.cs index b12c5229b3..4424576ec9 100644 --- a/src/Core/Models/Business/TaxInfo.cs +++ b/src/Core/Models/Business/TaxInfo.cs @@ -2,9 +2,18 @@ public class TaxInfo { - public string TaxIdNumber { get; set; } - public string TaxIdType { get; set; } + private string _taxIdNumber = null; + private string _taxIdType = null; + public string TaxIdNumber + { + get => _taxIdNumber; + set + { + _taxIdNumber = value; + _taxIdType = null; + } + } public string StripeTaxRateId { get; set; } public string BillingAddressLine1 { get; set; } public string BillingAddressLine2 { get; set; } @@ -12,6 +21,201 @@ public class TaxInfo public string BillingAddressState { get; set; } public string BillingAddressPostalCode { get; set; } public string BillingAddressCountry { get; set; } = "US"; + public string TaxIdType + { + get + { + if (string.IsNullOrWhiteSpace(BillingAddressCountry) || + string.IsNullOrWhiteSpace(TaxIdNumber)) + { + return null; + } + if (!string.IsNullOrWhiteSpace(_taxIdType)) + { + return _taxIdType; + } + + switch (BillingAddressCountry.ToUpper()) + { + case "AD": + _taxIdType = "ad_nrt"; + break; + case "AE": + _taxIdType = "ae_trn"; + break; + case "AR": + _taxIdType = "ar_cuit"; + break; + case "AU": + _taxIdType = "au_abn"; + break; + case "BO": + _taxIdType = "bo_tin"; + break; + case "BR": + _taxIdType = "br_cnpj"; + break; + case "CA": + // May break for those in Québec given the assumption of QST + if (BillingAddressState?.Contains("bec") ?? false) + { + _taxIdType = "ca_qst"; + break; + } + _taxIdType = "ca_bn"; + break; + case "CH": + _taxIdType = "ch_vat"; + break; + case "CL": + _taxIdType = "cl_tin"; + break; + case "CN": + _taxIdType = "cn_tin"; + break; + case "CO": + _taxIdType = "co_nit"; + break; + case "CR": + _taxIdType = "cr_tin"; + break; + case "DO": + _taxIdType = "do_rcn"; + break; + case "EC": + _taxIdType = "ec_ruc"; + break; + case "EG": + _taxIdType = "eg_tin"; + break; + case "GE": + _taxIdType = "ge_vat"; + break; + case "ID": + _taxIdType = "id_npwp"; + break; + case "IL": + _taxIdType = "il_vat"; + break; + case "IS": + _taxIdType = "is_vat"; + break; + case "KE": + _taxIdType = "ke_pin"; + break; + case "AT": + case "BE": + case "BG": + case "CY": + case "CZ": + case "DE": + case "DK": + case "EE": + case "ES": + case "FI": + case "FR": + case "GB": + case "GR": + case "HR": + case "HU": + case "IE": + case "IT": + case "LT": + case "LU": + case "LV": + case "MT": + case "NL": + case "PL": + case "PT": + case "RO": + case "SE": + case "SI": + case "SK": + _taxIdType = "eu_vat"; + break; + case "HK": + _taxIdType = "hk_br"; + break; + case "IN": + _taxIdType = "in_gst"; + break; + case "JP": + _taxIdType = "jp_cn"; + break; + case "KR": + _taxIdType = "kr_brn"; + break; + case "LI": + _taxIdType = "li_uid"; + break; + case "MX": + _taxIdType = "mx_rfc"; + break; + case "MY": + _taxIdType = "my_sst"; + break; + case "NO": + _taxIdType = "no_vat"; + break; + case "NZ": + _taxIdType = "nz_gst"; + break; + case "PE": + _taxIdType = "pe_ruc"; + break; + case "PH": + _taxIdType = "ph_tin"; + break; + case "RS": + _taxIdType = "rs_pib"; + break; + case "RU": + _taxIdType = "ru_inn"; + break; + case "SA": + _taxIdType = "sa_vat"; + break; + case "SG": + _taxIdType = "sg_gst"; + break; + case "SV": + _taxIdType = "sv_nit"; + break; + case "TH": + _taxIdType = "th_vat"; + break; + case "TR": + _taxIdType = "tr_tin"; + break; + case "TW": + _taxIdType = "tw_vat"; + break; + case "UA": + _taxIdType = "ua_vat"; + break; + case "US": + _taxIdType = "us_ein"; + break; + case "UY": + _taxIdType = "uy_ruc"; + break; + case "VE": + _taxIdType = "ve_rif"; + break; + case "VN": + _taxIdType = "vn_tin"; + break; + case "ZA": + _taxIdType = "za_vat"; + break; + default: + _taxIdType = null; + break; + } + + return _taxIdType; + } + } public bool HasTaxId { diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 7d0f9d3c63..bf9d047029 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -1,9 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Models.Api.Requests.Accounts; -using Bit.Core.Billing.Models.Api.Requests.Organizations; -using Bit.Core.Billing.Models.Api.Responses; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; @@ -62,7 +59,4 @@ public interface IPaymentService Task RisksSubscriptionFailure(Organization organization); Task HasSecretsManagerStandalone(Organization organization); Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Stripe.Subscription subscription); - Task PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); - Task PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId); - } diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index ef2e3ab766..30583ef0b3 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -31,7 +31,6 @@ public interface IStripeAdapter 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); @@ -43,7 +42,6 @@ public interface IStripeAdapter IAsyncEnumerable PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options); Task PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null); Task PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null); - Task PlanGetAsync(string id, Stripe.PlanGetOptions options = null); Task TaxRateCreateAsync(Stripe.TaxRateCreateOptions options); Task TaxRateUpdateAsync(string id, Stripe.TaxRateUpdateOptions options); Task TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options); diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index f4f8efe75f..8d18331456 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -15,7 +15,6 @@ public class StripeAdapter : IStripeAdapter 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 Stripe.TestHelpers.TestClockService _testClockService; @@ -34,7 +33,6 @@ public class StripeAdapter : IStripeAdapter _cardService = new Stripe.CardService(); _bankAccountService = new Stripe.BankAccountService(); _priceService = new Stripe.PriceService(); - _planService = new Stripe.PlanService(); _setupIntentService = new SetupIntentService(); _testClockService = new Stripe.TestHelpers.TestClockService(); _customerBalanceTransactionService = new CustomerBalanceTransactionService(); @@ -135,11 +133,6 @@ public class StripeAdapter : IStripeAdapter return invoices; } - public Task InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options) - { - return _invoiceService.CreatePreviewAsync(options); - } - public async Task> InvoiceSearchAsync(InvoiceSearchOptions options) => (await _invoiceService.SearchAsync(options)).Data; @@ -191,11 +184,6 @@ public class StripeAdapter : IStripeAdapter return _paymentMethodService.DetachAsync(id, options); } - public Task PlanGetAsync(string id, Stripe.PlanGetOptions options = null) - { - return _planService.GetAsync(id, options); - } - public Task TaxRateCreateAsync(Stripe.TaxRateCreateOptions options) { return _taxRateService.CreateAsync(options); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index c32dcd43ad..259a4eb757 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1,13 +1,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; -using Bit.Core.Billing.Models.Api.Requests.Accounts; -using Bit.Core.Billing.Models.Api.Requests.Organizations; -using Bit.Core.Billing.Models.Api.Responses; using Bit.Core.Billing.Models.Business; -using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -37,7 +32,6 @@ public class StripePaymentService : IPaymentService private readonly IStripeAdapter _stripeAdapter; private readonly IGlobalSettings _globalSettings; private readonly IFeatureService _featureService; - private readonly ITaxService _taxService; public StripePaymentService( ITransactionRepository transactionRepository, @@ -46,8 +40,7 @@ public class StripePaymentService : IPaymentService IStripeAdapter stripeAdapter, Braintree.IBraintreeGateway braintreeGateway, IGlobalSettings globalSettings, - IFeatureService featureService, - ITaxService taxService) + IFeatureService featureService) { _transactionRepository = transactionRepository; _logger = logger; @@ -56,7 +49,6 @@ public class StripePaymentService : IPaymentService _btGateway = braintreeGateway; _globalSettings = globalSettings; _featureService = featureService; - _taxService = taxService; } public async Task PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType, @@ -120,20 +112,6 @@ public class StripePaymentService : IPaymentService Subscription subscription; try { - if (taxInfo.TaxIdNumber != null && taxInfo.TaxIdType == null) - { - taxInfo.TaxIdType = _taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry, - taxInfo.TaxIdNumber); - - if (taxInfo.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"); - } - } - var customerCreateOptions = new CustomerCreateOptions { Description = org.DisplayBusinessName(), @@ -168,9 +146,12 @@ public class StripePaymentService : IPaymentService City = taxInfo?.BillingAddressCity, State = taxInfo?.BillingAddressState, }, - TaxIdData = taxInfo.HasTaxId - ? [new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber }] - : null + TaxIdData = taxInfo?.HasTaxId != true + ? null + : + [ + new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber, } + ], }; customerCreateOptions.AddExpand("tax"); @@ -1678,7 +1659,6 @@ public class StripePaymentService : IPaymentService return new TaxInfo { TaxIdNumber = taxId?.Value, - TaxIdType = taxId?.Type, BillingAddressLine1 = address?.Line1, BillingAddressLine2 = address?.Line2, BillingAddressCity = address?.City, @@ -1690,13 +1670,9 @@ public class StripePaymentService : IPaymentService public async Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo) { - if (string.IsNullOrWhiteSpace(subscriber?.GatewayCustomerId) || subscriber.IsUser()) + if (subscriber != null && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) { - return; - } - - var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, - new CustomerUpdateOptions + var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions { Address = new AddressOptions { @@ -1710,59 +1686,23 @@ public class StripePaymentService : IPaymentService Expand = ["tax_ids"] }); - if (customer == null) - { - return; - } - - var taxId = customer.TaxIds?.FirstOrDefault(); - - if (taxId != null) - { - await _stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); - } - - if (string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber)) - { - return; - } - - var taxIdType = taxInfo.TaxIdType; - - if (string.IsNullOrWhiteSpace(taxIdType)) - { - taxIdType = _taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber); - - if (taxIdType == null) + if (!subscriber.IsUser() && customer != null) { - _logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", - taxInfo.BillingAddressCountry, - taxInfo.TaxIdNumber); - throw new BadRequestException("billingTaxIdTypeInferenceError"); - } - } + var taxId = customer.TaxIds?.FirstOrDefault(); - try - { - await _stripeAdapter.TaxIdCreateAsync(customer.Id, - new TaxIdCreateOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber, }); - } - catch (StripeException e) - { - switch (e.StripeError.Code) - { - case StripeConstants.ErrorCodes.TaxIdInvalid: - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - taxInfo.TaxIdNumber, - taxInfo.BillingAddressCountry); - throw new BadRequestException("billingInvalidTaxIdError"); - default: - _logger.LogError(e, - "Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.", - taxInfo.TaxIdNumber, - taxInfo.BillingAddressCountry, - customer.Id); - throw new BadRequestException("billingTaxIdCreationError"); + if (taxId != null) + { + await _stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); + } + if (!string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber) && + !string.IsNullOrWhiteSpace(taxInfo.TaxIdType)) + { + await _stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions + { + Type = taxInfo.TaxIdType, + Value = taxInfo.TaxIdNumber, + }); + } } } } @@ -1895,285 +1835,6 @@ public class StripePaymentService : IPaymentService } } - public async Task PreviewInvoiceAsync( - PreviewIndividualInvoiceRequestBody parameters, - string gatewayCustomerId, - string gatewaySubscriptionId) - { - var options = new InvoiceCreatePreviewOptions - { - AutomaticTax = new InvoiceAutomaticTaxOptions - { - Enabled = true, - }, - Currency = "usd", - Discounts = new List(), - SubscriptionDetails = new InvoiceSubscriptionDetailsOptions - { - Items = - [ - new() - { - Quantity = 1, - Plan = "premium-annually" - }, - - new() - { - Quantity = parameters.PasswordManager.AdditionalStorage, - Plan = "storage-gb-annually" - } - ] - }, - CustomerDetails = new InvoiceCustomerDetailsOptions - { - Address = new AddressOptions - { - PostalCode = parameters.TaxInformation.PostalCode, - Country = parameters.TaxInformation.Country, - } - }, - }; - - if (!string.IsNullOrEmpty(parameters.TaxInformation.TaxId)) - { - var taxIdType = _taxService.GetStripeTaxCode( - options.CustomerDetails.Address.Country, - parameters.TaxInformation.TaxId); - - if (taxIdType == null) - { - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvalidTaxIdError"); - } - - options.CustomerDetails.TaxIds = [ - new InvoiceCustomerDetailsTaxIdOptions - { - Type = taxIdType, - Value = parameters.TaxInformation.TaxId - } - ]; - } - - if (gatewayCustomerId != null) - { - var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); - - if (gatewayCustomer.Discount != null) - { - options.Discounts.Add(new InvoiceDiscountOptions - { - Discount = gatewayCustomer.Discount.Id - }); - } - - if (gatewaySubscriptionId != null) - { - var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId); - - if (gatewaySubscription?.Discount != null) - { - options.Discounts.Add(new InvoiceDiscountOptions - { - Discount = gatewaySubscription.Discount.Id - }); - } - } - } - - try - { - var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - - var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null - ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() - : 0M; - - var result = new PreviewInvoiceResponseModel( - effectiveTaxRate, - invoice.TotalExcludingTax.ToMajor() ?? 0, - invoice.Tax.ToMajor() ?? 0, - invoice.Total.ToMajor()); - return result; - } - catch (StripeException e) - { - switch (e.StripeError.Code) - { - case StripeConstants.ErrorCodes.TaxIdInvalid: - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvalidTaxIdError"); - default: - _logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvoiceError"); - } - } - } - - public async Task PreviewInvoiceAsync( - PreviewOrganizationInvoiceRequestBody parameters, - string gatewayCustomerId, - string gatewaySubscriptionId) - { - var plan = Utilities.StaticStore.GetPlan(parameters.PasswordManager.Plan); - - var options = new InvoiceCreatePreviewOptions - { - AutomaticTax = new InvoiceAutomaticTaxOptions - { - Enabled = true, - }, - Currency = "usd", - Discounts = new List(), - SubscriptionDetails = new InvoiceSubscriptionDetailsOptions - { - Items = - [ - new() - { - Quantity = parameters.PasswordManager.AdditionalStorage, - Plan = plan.PasswordManager.StripeStoragePlanId - } - ] - }, - CustomerDetails = new InvoiceCustomerDetailsOptions - { - Address = new AddressOptions - { - PostalCode = parameters.TaxInformation.PostalCode, - Country = parameters.TaxInformation.Country, - } - }, - }; - - if (plan.PasswordManager.HasAdditionalSeatsOption) - { - options.SubscriptionDetails.Items.Add( - new() - { - Quantity = parameters.PasswordManager.Seats, - Plan = plan.PasswordManager.StripeSeatPlanId - } - ); - } - else - { - options.SubscriptionDetails.Items.Add( - new() - { - Quantity = 1, - Plan = plan.PasswordManager.StripePlanId - } - ); - } - - if (plan.SupportsSecretsManager) - { - if (plan.SecretsManager.HasAdditionalSeatsOption) - { - options.SubscriptionDetails.Items.Add(new() - { - Quantity = parameters.SecretsManager?.Seats ?? 0, - Plan = plan.SecretsManager.StripeSeatPlanId - }); - } - - if (plan.SecretsManager.HasAdditionalServiceAccountOption) - { - options.SubscriptionDetails.Items.Add(new() - { - Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, - Plan = plan.SecretsManager.StripeServiceAccountPlanId - }); - } - } - - if (!string.IsNullOrEmpty(parameters.TaxInformation.TaxId)) - { - var taxIdType = _taxService.GetStripeTaxCode( - options.CustomerDetails.Address.Country, - parameters.TaxInformation.TaxId); - - if (taxIdType == null) - { - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingTaxIdTypeInferenceError"); - } - - options.CustomerDetails.TaxIds = [ - new InvoiceCustomerDetailsTaxIdOptions - { - Type = taxIdType, - Value = parameters.TaxInformation.TaxId - } - ]; - } - - if (gatewayCustomerId != null) - { - var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); - - if (gatewayCustomer.Discount != null) - { - options.Discounts.Add(new InvoiceDiscountOptions - { - Discount = gatewayCustomer.Discount.Id - }); - } - - var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId); - - if (gatewaySubscription?.Discount != null) - { - options.Discounts.Add(new InvoiceDiscountOptions - { - Discount = gatewaySubscription.Discount.Id - }); - } - } - - try - { - var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - - var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null - ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() - : 0M; - - var result = new PreviewInvoiceResponseModel( - effectiveTaxRate, - invoice.TotalExcludingTax.ToMajor() ?? 0, - invoice.Tax.ToMajor() ?? 0, - invoice.Total.ToMajor()); - return result; - } - catch (StripeException e) - { - switch (e.StripeError.Code) - { - case StripeConstants.ErrorCodes.TaxIdInvalid: - _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvalidTaxIdError"); - default: - _logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", - parameters.TaxInformation.TaxId, - parameters.TaxInformation.Country); - throw new BadRequestException("billingPreviewInvoiceError"); - } - } - } - private PaymentMethod GetLatestCardPaymentMethod(string customerId) { var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging( diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 9c25ffdc55..385b185ffe 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -1545,7 +1545,7 @@ public class SubscriberServiceTests { var stripeAdapter = sutProvider.GetDependency(); - var customer = new Customer { Id = provider.GatewayCustomerId, TaxIds = new StripeList { Data = [new TaxId { Id = "tax_id_1", Type = "us_ein" }] } }; + var customer = new Customer { Id = provider.GatewayCustomerId, TaxIds = new StripeList { Data = [new TaxId { Id = "tax_id_1" }] } }; stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId, Arg.Is( options => options.Expand.Contains("tax_ids"))).Returns(customer); @@ -1554,7 +1554,6 @@ public class SubscriberServiceTests "US", "12345", "123456789", - "us_ein", "123 Example St.", null, "Example Town", diff --git a/test/Core.Test/Models/Business/TaxInfoTests.cs b/test/Core.Test/Models/Business/TaxInfoTests.cs new file mode 100644 index 0000000000..197948006e --- /dev/null +++ b/test/Core.Test/Models/Business/TaxInfoTests.cs @@ -0,0 +1,114 @@ +using Bit.Core.Models.Business; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class TaxInfoTests +{ + // PH = Placeholder + [Theory] + [InlineData(null, null, null, null)] + [InlineData("", "", null, null)] + [InlineData("PH", "", null, null)] + [InlineData("", "PH", null, null)] + [InlineData("AE", "PH", null, "ae_trn")] + [InlineData("AU", "PH", null, "au_abn")] + [InlineData("BR", "PH", null, "br_cnpj")] + [InlineData("CA", "PH", "bec", "ca_qst")] + [InlineData("CA", "PH", null, "ca_bn")] + [InlineData("CL", "PH", null, "cl_tin")] + [InlineData("AT", "PH", null, "eu_vat")] + [InlineData("BE", "PH", null, "eu_vat")] + [InlineData("BG", "PH", null, "eu_vat")] + [InlineData("CY", "PH", null, "eu_vat")] + [InlineData("CZ", "PH", null, "eu_vat")] + [InlineData("DE", "PH", null, "eu_vat")] + [InlineData("DK", "PH", null, "eu_vat")] + [InlineData("EE", "PH", null, "eu_vat")] + [InlineData("ES", "PH", null, "eu_vat")] + [InlineData("FI", "PH", null, "eu_vat")] + [InlineData("FR", "PH", null, "eu_vat")] + [InlineData("GB", "PH", null, "eu_vat")] + [InlineData("GR", "PH", null, "eu_vat")] + [InlineData("HR", "PH", null, "eu_vat")] + [InlineData("HU", "PH", null, "eu_vat")] + [InlineData("IE", "PH", null, "eu_vat")] + [InlineData("IT", "PH", null, "eu_vat")] + [InlineData("LT", "PH", null, "eu_vat")] + [InlineData("LU", "PH", null, "eu_vat")] + [InlineData("LV", "PH", null, "eu_vat")] + [InlineData("MT", "PH", null, "eu_vat")] + [InlineData("NL", "PH", null, "eu_vat")] + [InlineData("PL", "PH", null, "eu_vat")] + [InlineData("PT", "PH", null, "eu_vat")] + [InlineData("RO", "PH", null, "eu_vat")] + [InlineData("SE", "PH", null, "eu_vat")] + [InlineData("SI", "PH", null, "eu_vat")] + [InlineData("SK", "PH", null, "eu_vat")] + [InlineData("HK", "PH", null, "hk_br")] + [InlineData("IN", "PH", null, "in_gst")] + [InlineData("JP", "PH", null, "jp_cn")] + [InlineData("KR", "PH", null, "kr_brn")] + [InlineData("LI", "PH", null, "li_uid")] + [InlineData("MX", "PH", null, "mx_rfc")] + [InlineData("MY", "PH", null, "my_sst")] + [InlineData("NO", "PH", null, "no_vat")] + [InlineData("NZ", "PH", null, "nz_gst")] + [InlineData("RU", "PH", null, "ru_inn")] + [InlineData("SA", "PH", null, "sa_vat")] + [InlineData("SG", "PH", null, "sg_gst")] + [InlineData("TH", "PH", null, "th_vat")] + [InlineData("TW", "PH", null, "tw_vat")] + [InlineData("US", "PH", null, "us_ein")] + [InlineData("ZA", "PH", null, "za_vat")] + [InlineData("ABCDEF", "PH", null, null)] + public void GetTaxIdType_Success(string billingAddressCountry, + string taxIdNumber, + string billingAddressState, + string expectedTaxIdType) + { + var taxInfo = new TaxInfo + { + BillingAddressCountry = billingAddressCountry, + TaxIdNumber = taxIdNumber, + BillingAddressState = billingAddressState, + }; + + Assert.Equal(expectedTaxIdType, taxInfo.TaxIdType); + } + + [Fact] + public void GetTaxIdType_CreateOnce_ReturnCacheSecondTime() + { + var taxInfo = new TaxInfo + { + BillingAddressCountry = "US", + TaxIdNumber = "PH", + BillingAddressState = null, + }; + + Assert.Equal("us_ein", taxInfo.TaxIdType); + + // Per the current spec even if the values change to something other than null it + // will return the cached version of TaxIdType. + taxInfo.BillingAddressCountry = "ZA"; + + Assert.Equal("us_ein", taxInfo.TaxIdType); + } + + [Theory] + [InlineData(null, null, false)] + [InlineData("123", "US", true)] + [InlineData("123", "ZQ12", false)] + [InlineData(" ", "US", false)] + public void HasTaxId_ReturnsExpected(string taxIdNumber, string billingAddressCountry, bool expected) + { + var taxInfo = new TaxInfo + { + TaxIdNumber = taxIdNumber, + BillingAddressCountry = billingAddressCountry, + }; + + Assert.Equal(expected, taxInfo.HasTaxId); + } +} diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index 35e1901a2f..e15f07b113 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -77,8 +77,7 @@ public class StripePaymentServiceTests c.Address.Line2 == taxInfo.BillingAddressLine2 && c.Address.City == taxInfo.BillingAddressCity && c.Address.State == taxInfo.BillingAddressState && - c.TaxIdData.First().Value == taxInfo.TaxIdNumber && - c.TaxIdData.First().Type == taxInfo.TaxIdType + c.TaxIdData == null )); await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => @@ -135,8 +134,7 @@ public class StripePaymentServiceTests c.Address.Line2 == taxInfo.BillingAddressLine2 && c.Address.City == taxInfo.BillingAddressCity && c.Address.State == taxInfo.BillingAddressState && - c.TaxIdData.First().Value == taxInfo.TaxIdNumber && - c.TaxIdData.First().Type == taxInfo.TaxIdType + c.TaxIdData == null )); await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => @@ -192,8 +190,7 @@ public class StripePaymentServiceTests c.Address.Line2 == taxInfo.BillingAddressLine2 && c.Address.City == taxInfo.BillingAddressCity && c.Address.State == taxInfo.BillingAddressState && - c.TaxIdData.First().Value == taxInfo.TaxIdNumber && - c.TaxIdData.First().Type == taxInfo.TaxIdType + c.TaxIdData == null )); await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => @@ -250,8 +247,7 @@ public class StripePaymentServiceTests c.Address.Line2 == taxInfo.BillingAddressLine2 && c.Address.City == taxInfo.BillingAddressCity && c.Address.State == taxInfo.BillingAddressState && - c.TaxIdData.First().Value == taxInfo.TaxIdNumber && - c.TaxIdData.First().Type == taxInfo.TaxIdType + c.TaxIdData == null )); await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => @@ -445,8 +441,7 @@ public class StripePaymentServiceTests c.Address.Line2 == taxInfo.BillingAddressLine2 && c.Address.City == taxInfo.BillingAddressCity && c.Address.State == taxInfo.BillingAddressState && - c.TaxIdData.First().Value == taxInfo.TaxIdNumber && - c.TaxIdData.First().Type == taxInfo.TaxIdType + c.TaxIdData == null )); await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => @@ -515,8 +510,7 @@ public class StripePaymentServiceTests c.Address.Line2 == taxInfo.BillingAddressLine2 && c.Address.City == taxInfo.BillingAddressCity && c.Address.State == taxInfo.BillingAddressState && - c.TaxIdData.First().Value == taxInfo.TaxIdNumber && - c.TaxIdData.First().Type == taxInfo.TaxIdType + c.TaxIdData == null )); await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is(s => From 3c75ff335b3faee4f924aa97ea089a8454cc4698 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:36:37 -0500 Subject: [PATCH 18/30] [PM-15536] Allow reseller to add organization (#5111) * Allow reseller to add organization * Run dotnet format --- .../AdminConsole/Services/ProviderService.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index e384d71df9..864466ad45 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -27,7 +27,11 @@ namespace Bit.Commercial.Core.AdminConsole.Services; public class ProviderService : IProviderService { - public static PlanType[] ProviderDisallowedOrganizationTypes = new[] { PlanType.Free, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019 }; + private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [ + PlanType.Free, + PlanType.FamiliesAnnually, + PlanType.FamiliesAnnually2019 + ]; private readonly IDataProtector _dataProtector; private readonly IMailService _mailService; @@ -690,13 +694,14 @@ public class ProviderService : IProviderService throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed."); } break; + case ProviderType.Reseller: + if (_resellerDisallowedOrganizationTypes.Contains(requestedType)) + { + throw new BadRequestException($"Providers cannot manage organizations with the requested plan type ({requestedType}). Only Teams and Enterprise accounts are allowed."); + } + break; default: throw new BadRequestException($"Unsupported provider type {providerType}."); } - - if (ProviderDisallowedOrganizationTypes.Contains(requestedType)) - { - throw new BadRequestException($"Providers cannot manage organizations with the requested plan type ({requestedType}). Only Teams and Enterprise accounts are allowed."); - } } } From 74e86935a45dee256154a6fad92f80f54f3a4887 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:19:10 -0600 Subject: [PATCH 19/30] add `PM9111ExtensionPersistAddEditForm` feature flag (#5106) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 6efdedfeed..1d807149a9 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -155,6 +155,7 @@ public static class FeatureFlagKeys public const string PM14401_ScaleMSPOnClientOrganizationUpdate = "PM-14401-scale-msp-on-client-organization-update"; public const string DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship"; public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; + public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; public static List GetAllKeys() { From 0e32dcccadd759e938542dc8069bc7be9b4e297b Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Wed, 4 Dec 2024 14:42:12 -0500 Subject: [PATCH 20/30] Update Constants.cs (#5112) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 1d807149a9..46ed985cb8 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -156,6 +156,7 @@ public static class FeatureFlagKeys public const string DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship"; public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; + public const string InlineMenuTotp = "inline-menu-totp"; public static List GetAllKeys() { From 04cf513d7868085e0c954b5bd2220991e75cd19a Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Thu, 5 Dec 2024 09:31:14 -0500 Subject: [PATCH 21/30] [PM-11516] Initial license file refactor (#5002) * Added the ability to create a JWT on an organization license that contains all license properties as claims * Added the ability to create a JWT on a user license that contains all license properties as claims * Added ability to consume JWT licenses * Resolved generic type issues when getting claim value * Now validating the jwt signature, exp, and iat * Moved creation of ClaimsPrincipal outside of licenses given dependecy on cert * Ran dotnet format. Resolved identity error * Updated claim types to use string constants * Updated jwt expires to be one year * Fixed bug requiring email verification to be on the token * dotnet format * Patch build process --------- Co-authored-by: Matt Bishop --- .../Implementations/OrganizationService.cs | 3 +- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Licenses/Extensions/LicenseExtensions.cs | 151 ++++++++++ .../LicenseServiceCollectionExtensions.cs | 16 + src/Core/Billing/Licenses/LicenseConstants.cs | 58 ++++ .../Billing/Licenses/Models/LicenseContext.cs | 10 + .../Services/ILicenseClaimsFactory.cs | 9 + .../OrganizationLicenseClaimsFactory.cs | 75 +++++ .../UserLicenseClaimsFactory.cs | 37 +++ src/Core/Constants.cs | 1 + src/Core/Models/Business/ILicense.cs | 1 + .../Models/Business/OrganizationLicense.cs | 273 +++++++++++++----- src/Core/Models/Business/UserLicense.cs | 83 +++++- .../Cloud/CloudGetOrganizationLicenseQuery.cs | 6 +- .../UpdateOrganizationLicenseCommand.cs | 3 +- src/Core/Services/ILicensingService.cs | 10 +- .../Implementations/LicensingService.cs | 145 +++++++++- .../Services/Implementations/UserService.cs | 21 +- .../NoopLicensingService.cs | 18 +- .../Utilities/ServiceCollectionExtensions.cs | 2 +- .../Business/OrganizationLicenseTests.cs | 7 +- .../UpdateOrganizationLicenseCommandTests.cs | 15 +- test/Core.Test/Services/UserServiceTests.cs | 6 +- 23 files changed, 846 insertions(+), 106 deletions(-) create mode 100644 src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs create mode 100644 src/Core/Billing/Licenses/Extensions/LicenseServiceCollectionExtensions.cs create mode 100644 src/Core/Billing/Licenses/LicenseConstants.cs create mode 100644 src/Core/Billing/Licenses/Models/LicenseContext.cs create mode 100644 src/Core/Billing/Licenses/Services/ILicenseClaimsFactory.cs create mode 100644 src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs create mode 100644 src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index e8756fb325..862b566c91 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -642,7 +642,8 @@ public class OrganizationService : IOrganizationService OrganizationLicense license, User owner, string ownerKey, string collectionName, string publicKey, string privateKey) { - var canUse = license.CanUse(_globalSettings, _licensingService, out var exception); + var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); + var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception); if (!canUse) { throw new BadRequestException(exception); diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index abfceac736..78253f7399 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches.Implementations; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; @@ -15,5 +16,6 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddLicenseServices(); } } diff --git a/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs b/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs new file mode 100644 index 0000000000..184d8dad23 --- /dev/null +++ b/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs @@ -0,0 +1,151 @@ +using System.Security.Claims; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Models.Business; + +namespace Bit.Core.Billing.Licenses.Extensions; + +public static class LicenseExtensions +{ + public static DateTime CalculateFreshExpirationDate(this Organization org, SubscriptionInfo subscriptionInfo) + { + if (subscriptionInfo?.Subscription == null) + { + if (org.PlanType == PlanType.Custom && org.ExpirationDate.HasValue) + { + return org.ExpirationDate.Value; + } + + return DateTime.UtcNow.AddDays(7); + } + + var subscription = subscriptionInfo.Subscription; + + if (subscription.TrialEndDate > DateTime.UtcNow) + { + return subscription.TrialEndDate.Value; + } + + if (org.ExpirationDate.HasValue && org.ExpirationDate.Value < DateTime.UtcNow) + { + return org.ExpirationDate.Value; + } + + if (subscription.PeriodEndDate.HasValue && subscription.PeriodDuration > TimeSpan.FromDays(180)) + { + return subscription.PeriodEndDate + .Value + .AddDays(Bit.Core.Constants.OrganizationSelfHostSubscriptionGracePeriodDays); + } + + return org.ExpirationDate?.AddMonths(11) ?? DateTime.UtcNow.AddYears(1); + } + + public static DateTime CalculateFreshRefreshDate(this Organization org, SubscriptionInfo subscriptionInfo, DateTime expirationDate) + { + 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) + { + return expirationDate; + } + + var subscription = subscriptionInfo.Subscription; + + if (subscription.TrialEndDate <= DateTime.UtcNow && + org.ExpirationDate >= DateTime.UtcNow && + subscription.PeriodEndDate.HasValue && + subscription.PeriodDuration > TimeSpan.FromDays(180)) + { + return subscription.PeriodEndDate.Value; + } + + return expirationDate; + } + + public static T GetValue(this ClaimsPrincipal principal, string claimType) + { + var claim = principal.FindFirst(claimType); + + if (claim is null) + { + return default; + } + + // Handle Guid + if (typeof(T) == typeof(Guid)) + { + return Guid.TryParse(claim.Value, out var guid) + ? (T)(object)guid + : default; + } + + // Handle DateTime + if (typeof(T) == typeof(DateTime)) + { + return DateTime.TryParse(claim.Value, out var dateTime) + ? (T)(object)dateTime + : default; + } + + // Handle TimeSpan + if (typeof(T) == typeof(TimeSpan)) + { + return TimeSpan.TryParse(claim.Value, out var timeSpan) + ? (T)(object)timeSpan + : default; + } + + // Check for Nullable Types + var underlyingType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + + // Handle Enums + if (underlyingType.IsEnum) + { + if (Enum.TryParse(underlyingType, claim.Value, true, out var enumValue)) + { + return (T)enumValue; // Cast back to T + } + + return default; // Return default value for non-nullable enums or null for nullable enums + } + + // Handle other Nullable Types (e.g., int?, bool?) + if (underlyingType == typeof(int)) + { + return int.TryParse(claim.Value, out var intValue) + ? (T)(object)intValue + : default; + } + + if (underlyingType == typeof(bool)) + { + return bool.TryParse(claim.Value, out var boolValue) + ? (T)(object)boolValue + : default; + } + + if (underlyingType == typeof(double)) + { + return double.TryParse(claim.Value, out var doubleValue) + ? (T)(object)doubleValue + : default; + } + + // Fallback to Convert.ChangeType for other types including strings + return (T)Convert.ChangeType(claim.Value, underlyingType); + } +} diff --git a/src/Core/Billing/Licenses/Extensions/LicenseServiceCollectionExtensions.cs b/src/Core/Billing/Licenses/Extensions/LicenseServiceCollectionExtensions.cs new file mode 100644 index 0000000000..b08adbd004 --- /dev/null +++ b/src/Core/Billing/Licenses/Extensions/LicenseServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Licenses.Services; +using Bit.Core.Billing.Licenses.Services.Implementations; +using Bit.Core.Entities; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Billing.Licenses.Extensions; + +public static class LicenseServiceCollectionExtensions +{ + public static void AddLicenseServices(this IServiceCollection services) + { + services.AddTransient, OrganizationLicenseClaimsFactory>(); + services.AddTransient, UserLicenseClaimsFactory>(); + } +} diff --git a/src/Core/Billing/Licenses/LicenseConstants.cs b/src/Core/Billing/Licenses/LicenseConstants.cs new file mode 100644 index 0000000000..564019affc --- /dev/null +++ b/src/Core/Billing/Licenses/LicenseConstants.cs @@ -0,0 +1,58 @@ +namespace Bit.Core.Billing.Licenses; + +public static class OrganizationLicenseConstants +{ + public const string LicenseType = nameof(LicenseType); + public const string LicenseKey = nameof(LicenseKey); + public const string InstallationId = nameof(InstallationId); + public const string Id = nameof(Id); + public const string Name = nameof(Name); + public const string BusinessName = nameof(BusinessName); + public const string BillingEmail = nameof(BillingEmail); + public const string Enabled = nameof(Enabled); + public const string Plan = nameof(Plan); + public const string PlanType = nameof(PlanType); + public const string Seats = nameof(Seats); + public const string MaxCollections = nameof(MaxCollections); + public const string UsePolicies = nameof(UsePolicies); + public const string UseSso = nameof(UseSso); + public const string UseKeyConnector = nameof(UseKeyConnector); + public const string UseScim = nameof(UseScim); + public const string UseGroups = nameof(UseGroups); + public const string UseEvents = nameof(UseEvents); + public const string UseDirectory = nameof(UseDirectory); + public const string UseTotp = nameof(UseTotp); + public const string Use2fa = nameof(Use2fa); + public const string UseApi = nameof(UseApi); + public const string UseResetPassword = nameof(UseResetPassword); + public const string MaxStorageGb = nameof(MaxStorageGb); + public const string SelfHost = nameof(SelfHost); + public const string UsersGetPremium = nameof(UsersGetPremium); + public const string UseCustomPermissions = nameof(UseCustomPermissions); + public const string Issued = nameof(Issued); + public const string UsePasswordManager = nameof(UsePasswordManager); + public const string UseSecretsManager = nameof(UseSecretsManager); + public const string SmSeats = nameof(SmSeats); + public const string SmServiceAccounts = nameof(SmServiceAccounts); + public const string LimitCollectionCreationDeletion = nameof(LimitCollectionCreationDeletion); + public const string AllowAdminAccessToAllCollectionItems = nameof(AllowAdminAccessToAllCollectionItems); + public const string Expires = nameof(Expires); + public const string Refresh = nameof(Refresh); + public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod); + public const string Trial = nameof(Trial); +} + +public static class UserLicenseConstants +{ + public const string LicenseType = nameof(LicenseType); + public const string LicenseKey = nameof(LicenseKey); + public const string Id = nameof(Id); + public const string Name = nameof(Name); + public const string Email = nameof(Email); + public const string Premium = nameof(Premium); + public const string MaxStorageGb = nameof(MaxStorageGb); + public const string Issued = nameof(Issued); + public const string Expires = nameof(Expires); + public const string Refresh = nameof(Refresh); + public const string Trial = nameof(Trial); +} diff --git a/src/Core/Billing/Licenses/Models/LicenseContext.cs b/src/Core/Billing/Licenses/Models/LicenseContext.cs new file mode 100644 index 0000000000..8dcc24e939 --- /dev/null +++ b/src/Core/Billing/Licenses/Models/LicenseContext.cs @@ -0,0 +1,10 @@ +#nullable enable +using Bit.Core.Models.Business; + +namespace Bit.Core.Billing.Licenses.Models; + +public class LicenseContext +{ + public Guid? InstallationId { get; init; } + public required SubscriptionInfo SubscriptionInfo { get; init; } +} diff --git a/src/Core/Billing/Licenses/Services/ILicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/ILicenseClaimsFactory.cs new file mode 100644 index 0000000000..926ad04683 --- /dev/null +++ b/src/Core/Billing/Licenses/Services/ILicenseClaimsFactory.cs @@ -0,0 +1,9 @@ +using System.Security.Claims; +using Bit.Core.Billing.Licenses.Models; + +namespace Bit.Core.Billing.Licenses.Services; + +public interface ILicenseClaimsFactory +{ + Task> GenerateClaims(T entity, LicenseContext licenseContext); +} diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs new file mode 100644 index 0000000000..300b87dcca --- /dev/null +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -0,0 +1,75 @@ +using System.Globalization; +using System.Security.Claims; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +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; + +public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory +{ + public Task> GenerateClaims(Organization entity, LicenseContext licenseContext) + { + 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 claims = new List + { + new(nameof(OrganizationLicenseConstants.LicenseType), LicenseType.Organization.ToString()), + new Claim(nameof(OrganizationLicenseConstants.LicenseKey), entity.LicenseKey), + new(nameof(OrganizationLicenseConstants.InstallationId), licenseContext.InstallationId.ToString()), + new(nameof(OrganizationLicenseConstants.Id), entity.Id.ToString()), + new(nameof(OrganizationLicenseConstants.Name), entity.Name), + new(nameof(OrganizationLicenseConstants.BillingEmail), entity.BillingEmail), + new(nameof(OrganizationLicenseConstants.Enabled), entity.Enabled.ToString()), + new(nameof(OrganizationLicenseConstants.Plan), entity.Plan), + new(nameof(OrganizationLicenseConstants.PlanType), entity.PlanType.ToString()), + new(nameof(OrganizationLicenseConstants.Seats), entity.Seats.ToString()), + new(nameof(OrganizationLicenseConstants.MaxCollections), entity.MaxCollections.ToString()), + new(nameof(OrganizationLicenseConstants.UsePolicies), entity.UsePolicies.ToString()), + new(nameof(OrganizationLicenseConstants.UseSso), entity.UseSso.ToString()), + new(nameof(OrganizationLicenseConstants.UseKeyConnector), entity.UseKeyConnector.ToString()), + new(nameof(OrganizationLicenseConstants.UseScim), entity.UseScim.ToString()), + new(nameof(OrganizationLicenseConstants.UseGroups), entity.UseGroups.ToString()), + new(nameof(OrganizationLicenseConstants.UseEvents), entity.UseEvents.ToString()), + new(nameof(OrganizationLicenseConstants.UseDirectory), entity.UseDirectory.ToString()), + new(nameof(OrganizationLicenseConstants.UseTotp), entity.UseTotp.ToString()), + new(nameof(OrganizationLicenseConstants.Use2fa), entity.Use2fa.ToString()), + new(nameof(OrganizationLicenseConstants.UseApi), entity.UseApi.ToString()), + new(nameof(OrganizationLicenseConstants.UseResetPassword), entity.UseResetPassword.ToString()), + new(nameof(OrganizationLicenseConstants.MaxStorageGb), entity.MaxStorageGb.ToString()), + new(nameof(OrganizationLicenseConstants.SelfHost), entity.SelfHost.ToString()), + new(nameof(OrganizationLicenseConstants.UsersGetPremium), entity.UsersGetPremium.ToString()), + new(nameof(OrganizationLicenseConstants.UseCustomPermissions), entity.UseCustomPermissions.ToString()), + new(nameof(OrganizationLicenseConstants.Issued), DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)), + new(nameof(OrganizationLicenseConstants.UsePasswordManager), entity.UsePasswordManager.ToString()), + new(nameof(OrganizationLicenseConstants.UseSecretsManager), entity.UseSecretsManager.ToString()), + new(nameof(OrganizationLicenseConstants.SmSeats), entity.SmSeats.ToString()), + new(nameof(OrganizationLicenseConstants.SmServiceAccounts), entity.SmServiceAccounts.ToString()), + new(nameof(OrganizationLicenseConstants.LimitCollectionCreationDeletion), entity.LimitCollectionCreationDeletion.ToString()), + new(nameof(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems), entity.AllowAdminAccessToAllCollectionItems.ToString()), + new(nameof(OrganizationLicenseConstants.Expires), expires.ToString(CultureInfo.InvariantCulture)), + new(nameof(OrganizationLicenseConstants.Refresh), refresh.ToString(CultureInfo.InvariantCulture)), + new(nameof(OrganizationLicenseConstants.ExpirationWithoutGracePeriod), expirationWithoutGracePeriod.ToString(CultureInfo.InvariantCulture)), + new(nameof(OrganizationLicenseConstants.Trial), trial.ToString()), + }; + + if (entity.BusinessName is not null) + { + claims.Add(new Claim(nameof(OrganizationLicenseConstants.BusinessName), entity.BusinessName)); + } + + return Task.FromResult(claims); + } + + private static bool IsTrialing(Organization org, SubscriptionInfo subscriptionInfo) => + subscriptionInfo?.Subscription is null + ? org.PlanType != PlanType.Custom || !org.ExpirationDate.HasValue + : subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow; +} diff --git a/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs new file mode 100644 index 0000000000..28c779c3d6 --- /dev/null +++ b/src/Core/Billing/Licenses/Services/Implementations/UserLicenseClaimsFactory.cs @@ -0,0 +1,37 @@ +using System.Globalization; +using System.Security.Claims; +using Bit.Core.Billing.Licenses.Models; +using Bit.Core.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.Billing.Licenses.Services.Implementations; + +public class UserLicenseClaimsFactory : ILicenseClaimsFactory +{ + public Task> GenerateClaims(User entity, LicenseContext licenseContext) + { + var subscriptionInfo = licenseContext.SubscriptionInfo; + + var expires = subscriptionInfo.UpcomingInvoice?.Date?.AddDays(7) ?? entity.PremiumExpirationDate?.AddDays(7); + var refresh = subscriptionInfo.UpcomingInvoice?.Date ?? entity.PremiumExpirationDate; + var trial = (subscriptionInfo.Subscription?.TrialEndDate.HasValue ?? false) && + subscriptionInfo.Subscription.TrialEndDate.Value > DateTime.UtcNow; + + var claims = new List + { + new(nameof(UserLicenseConstants.LicenseType), LicenseType.User.ToString()), + new(nameof(UserLicenseConstants.LicenseKey), entity.LicenseKey), + new(nameof(UserLicenseConstants.Id), entity.Id.ToString()), + new(nameof(UserLicenseConstants.Name), entity.Name), + new(nameof(UserLicenseConstants.Email), entity.Email), + new(nameof(UserLicenseConstants.Premium), entity.Premium.ToString()), + new(nameof(UserLicenseConstants.MaxStorageGb), entity.MaxStorageGb.ToString()), + new(nameof(UserLicenseConstants.Issued), DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)), + new(nameof(UserLicenseConstants.Expires), expires.ToString()), + new(nameof(UserLicenseConstants.Refresh), refresh.ToString()), + new(nameof(UserLicenseConstants.Trial), trial.ToString()), + }; + + return Task.FromResult(claims); + } +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 46ed985cb8..14258353d6 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -157,6 +157,7 @@ public static class FeatureFlagKeys public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; public const string InlineMenuTotp = "inline-menu-totp"; + public const string SelfHostLicenseRefactor = "pm-11516-self-host-license-refactor"; public static List GetAllKeys() { diff --git a/src/Core/Models/Business/ILicense.cs b/src/Core/Models/Business/ILicense.cs index ad389b0a12..b0e295bdd9 100644 --- a/src/Core/Models/Business/ILicense.cs +++ b/src/Core/Models/Business/ILicense.cs @@ -12,6 +12,7 @@ public interface ILicense bool Trial { get; set; } string Hash { get; set; } string Signature { get; set; } + string Token { get; set; } byte[] SignatureBytes { get; } byte[] GetDataBytes(bool forHash = false); byte[] ComputeHash(); diff --git a/src/Core/Models/Business/OrganizationLicense.cs b/src/Core/Models/Business/OrganizationLicense.cs index ea51273645..37a086646c 100644 --- a/src/Core/Models/Business/OrganizationLicense.cs +++ b/src/Core/Models/Business/OrganizationLicense.cs @@ -1,10 +1,12 @@ using System.Reflection; +using System.Security.Claims; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Settings; @@ -151,6 +153,7 @@ public class OrganizationLicense : ILicense public LicenseType? LicenseType { get; set; } public string Hash { get; set; } public string Signature { get; set; } + public string Token { get; set; } [JsonIgnore] public byte[] SignatureBytes => Convert.FromBase64String(Signature); /// @@ -176,6 +179,7 @@ public class OrganizationLicense : ILicense !p.Name.Equals(nameof(Signature)) && !p.Name.Equals(nameof(SignatureBytes)) && !p.Name.Equals(nameof(LicenseType)) && + !p.Name.Equals(nameof(Token)) && // UsersGetPremium was added in Version 2 (Version >= 2 || !p.Name.Equals(nameof(UsersGetPremium))) && // UseEvents was added in Version 3 @@ -236,8 +240,65 @@ public class OrganizationLicense : ILicense } } - public bool CanUse(IGlobalSettings globalSettings, ILicensingService licensingService, out string exception) + public bool CanUse( + IGlobalSettings globalSettings, + ILicensingService licensingService, + ClaimsPrincipal claimsPrincipal, + out string exception) { + if (string.IsNullOrWhiteSpace(Token) || claimsPrincipal is null) + { + return ObsoleteCanUse(globalSettings, licensingService, out exception); + } + + var errorMessages = new StringBuilder(); + + var enabled = claimsPrincipal.GetValue(nameof(Enabled)); + if (!enabled) + { + errorMessages.AppendLine("Your cloud-hosted organization is currently disabled."); + } + + var installationId = claimsPrincipal.GetValue(nameof(InstallationId)); + if (installationId != globalSettings.Installation.Id) + { + errorMessages.AppendLine("The installation ID does not match the current installation."); + } + + var selfHost = claimsPrincipal.GetValue(nameof(SelfHost)); + if (!selfHost) + { + errorMessages.AppendLine("The license does not allow for on-premise hosting of organizations."); + } + + var licenseType = claimsPrincipal.GetValue(nameof(LicenseType)); + if (licenseType != Enums.LicenseType.Organization) + { + errorMessages.AppendLine("Premium licenses cannot be applied to an organization. " + + "Upload this license from your personal account settings page."); + } + + if (errorMessages.Length > 0) + { + exception = $"Invalid license. {errorMessages.ToString().TrimEnd()}"; + return false; + } + + exception = ""; + return true; + } + + /// + /// Do not extend this method. It is only here for backwards compatibility with old licenses. + /// Instead, extend the CanUse method using the ClaimsPrincipal. + /// + /// + /// + /// + /// + private bool ObsoleteCanUse(IGlobalSettings globalSettings, ILicensingService licensingService, out string exception) + { + // Do not extend this method. It is only here for backwards compatibility with old licenses. var errorMessages = new StringBuilder(); if (!Enabled) @@ -291,101 +352,177 @@ public class OrganizationLicense : ILicense return true; } - public bool VerifyData(Organization organization, IGlobalSettings globalSettings) + public bool VerifyData( + Organization organization, + ClaimsPrincipal claimsPrincipal, + IGlobalSettings globalSettings) { + if (string.IsNullOrWhiteSpace(Token)) + { + return ObsoleteVerifyData(organization, globalSettings); + } + + var issued = claimsPrincipal.GetValue(nameof(Issued)); + var expires = claimsPrincipal.GetValue(nameof(Expires)); + var installationId = claimsPrincipal.GetValue(nameof(InstallationId)); + var licenseKey = claimsPrincipal.GetValue(nameof(LicenseKey)); + var enabled = claimsPrincipal.GetValue(nameof(Enabled)); + var planType = claimsPrincipal.GetValue(nameof(PlanType)); + var seats = claimsPrincipal.GetValue(nameof(Seats)); + var maxCollections = claimsPrincipal.GetValue(nameof(MaxCollections)); + var useGroups = claimsPrincipal.GetValue(nameof(UseGroups)); + var useDirectory = claimsPrincipal.GetValue(nameof(UseDirectory)); + var useTotp = claimsPrincipal.GetValue(nameof(UseTotp)); + var selfHost = claimsPrincipal.GetValue(nameof(SelfHost)); + var name = claimsPrincipal.GetValue(nameof(Name)); + var usersGetPremium = claimsPrincipal.GetValue(nameof(UsersGetPremium)); + var useEvents = claimsPrincipal.GetValue(nameof(UseEvents)); + var use2fa = claimsPrincipal.GetValue(nameof(Use2fa)); + var useApi = claimsPrincipal.GetValue(nameof(UseApi)); + var usePolicies = claimsPrincipal.GetValue(nameof(UsePolicies)); + var useSso = claimsPrincipal.GetValue(nameof(UseSso)); + var useResetPassword = claimsPrincipal.GetValue(nameof(UseResetPassword)); + var useKeyConnector = claimsPrincipal.GetValue(nameof(UseKeyConnector)); + var useScim = claimsPrincipal.GetValue(nameof(UseScim)); + var useCustomPermissions = claimsPrincipal.GetValue(nameof(UseCustomPermissions)); + var useSecretsManager = claimsPrincipal.GetValue(nameof(UseSecretsManager)); + var usePasswordManager = claimsPrincipal.GetValue(nameof(UsePasswordManager)); + var smSeats = claimsPrincipal.GetValue(nameof(SmSeats)); + var smServiceAccounts = claimsPrincipal.GetValue(nameof(SmServiceAccounts)); + + return issued <= DateTime.UtcNow && + expires >= DateTime.UtcNow && + installationId == globalSettings.Installation.Id && + licenseKey == organization.LicenseKey && + enabled == organization.Enabled && + planType == organization.PlanType && + seats == organization.Seats && + maxCollections == organization.MaxCollections && + useGroups == organization.UseGroups && + useDirectory == organization.UseDirectory && + useTotp == organization.UseTotp && + selfHost == organization.SelfHost && + name == organization.Name && + usersGetPremium == organization.UsersGetPremium && + useEvents == organization.UseEvents && + use2fa == organization.Use2fa && + useApi == organization.UseApi && + usePolicies == organization.UsePolicies && + useSso == organization.UseSso && + useResetPassword == organization.UseResetPassword && + useKeyConnector == organization.UseKeyConnector && + useScim == organization.UseScim && + useCustomPermissions == organization.UseCustomPermissions && + useSecretsManager == organization.UseSecretsManager && + usePasswordManager == organization.UsePasswordManager && + smSeats == organization.SmSeats && + smServiceAccounts == organization.SmServiceAccounts; + } + + /// + /// Do not extend this method. It is only here for backwards compatibility with old licenses. + /// Instead, extend the VerifyData method using the ClaimsPrincipal. + /// + /// + /// + /// + /// + private bool ObsoleteVerifyData(Organization organization, IGlobalSettings globalSettings) + { + // Do not extend this method. It is only here for backwards compatibility with old licenses. if (Issued > DateTime.UtcNow || Expires < DateTime.UtcNow) { return false; } - if (ValidLicenseVersion) + if (!ValidLicenseVersion) { - var valid = - globalSettings.Installation.Id == InstallationId && - organization.LicenseKey != null && organization.LicenseKey.Equals(LicenseKey) && - organization.Enabled == Enabled && - organization.PlanType == PlanType && - organization.Seats == Seats && - organization.MaxCollections == MaxCollections && - organization.UseGroups == UseGroups && - organization.UseDirectory == UseDirectory && - organization.UseTotp == UseTotp && - organization.SelfHost == SelfHost && - organization.Name.Equals(Name); + throw new NotSupportedException($"Version {Version} is not supported."); + } - if (valid && Version >= 2) - { - valid = organization.UsersGetPremium == UsersGetPremium; - } + var valid = + globalSettings.Installation.Id == InstallationId && + organization.LicenseKey != null && organization.LicenseKey.Equals(LicenseKey) && + organization.Enabled == Enabled && + organization.PlanType == PlanType && + organization.Seats == Seats && + organization.MaxCollections == MaxCollections && + organization.UseGroups == UseGroups && + organization.UseDirectory == UseDirectory && + organization.UseTotp == UseTotp && + organization.SelfHost == SelfHost && + organization.Name.Equals(Name); - if (valid && Version >= 3) - { - valid = organization.UseEvents == UseEvents; - } + if (valid && Version >= 2) + { + valid = organization.UsersGetPremium == UsersGetPremium; + } - if (valid && Version >= 4) - { - valid = organization.Use2fa == Use2fa; - } + if (valid && Version >= 3) + { + valid = organization.UseEvents == UseEvents; + } - if (valid && Version >= 5) - { - valid = organization.UseApi == UseApi; - } + if (valid && Version >= 4) + { + valid = organization.Use2fa == Use2fa; + } - if (valid && Version >= 6) - { - valid = organization.UsePolicies == UsePolicies; - } + if (valid && Version >= 5) + { + valid = organization.UseApi == UseApi; + } - if (valid && Version >= 7) - { - valid = organization.UseSso == UseSso; - } + if (valid && Version >= 6) + { + valid = organization.UsePolicies == UsePolicies; + } - if (valid && Version >= 8) - { - valid = organization.UseResetPassword == UseResetPassword; - } + if (valid && Version >= 7) + { + valid = organization.UseSso == UseSso; + } - if (valid && Version >= 9) - { - valid = organization.UseKeyConnector == UseKeyConnector; - } + if (valid && Version >= 8) + { + valid = organization.UseResetPassword == UseResetPassword; + } - if (valid && Version >= 10) - { - valid = organization.UseScim == UseScim; - } + if (valid && Version >= 9) + { + valid = organization.UseKeyConnector == UseKeyConnector; + } - if (valid && Version >= 11) - { - valid = organization.UseCustomPermissions == UseCustomPermissions; - } + if (valid && Version >= 10) + { + valid = organization.UseScim == UseScim; + } - /*Version 12 added ExpirationWithoutDatePeriod, but that property is informational only and is not saved + if (valid && Version >= 11) + { + valid = organization.UseCustomPermissions == UseCustomPermissions; + } + + /*Version 12 added ExpirationWithoutDatePeriod, but that property is informational only and is not saved to the Organization object. It's validated as part of the hash but does not need to be validated here. */ - if (valid && Version >= 13) - { - valid = organization.UseSecretsManager == UseSecretsManager && - organization.UsePasswordManager == UsePasswordManager && - organization.SmSeats == SmSeats && - organization.SmServiceAccounts == SmServiceAccounts; - } + if (valid && Version >= 13) + { + valid = organization.UseSecretsManager == UseSecretsManager && + organization.UsePasswordManager == UsePasswordManager && + organization.SmSeats == SmSeats && + organization.SmServiceAccounts == SmServiceAccounts; + } - /* + /* * Version 14 added LimitCollectionCreationDeletion and Version * 15 added AllowAdminAccessToAllCollectionItems, however they * are no longer used and are intentionally excluded from * validation. */ - return valid; - } - - throw new NotSupportedException($"Version {Version} is not supported."); + return valid; } public bool VerifySignature(X509Certificate2 certificate) diff --git a/src/Core/Models/Business/UserLicense.cs b/src/Core/Models/Business/UserLicense.cs index 0f1b191a1d..797aa6692a 100644 --- a/src/Core/Models/Business/UserLicense.cs +++ b/src/Core/Models/Business/UserLicense.cs @@ -1,8 +1,10 @@ using System.Reflection; +using System.Security.Claims; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json.Serialization; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Services; @@ -70,6 +72,7 @@ public class UserLicense : ILicense public LicenseType? LicenseType { get; set; } public string Hash { get; set; } public string Signature { get; set; } + public string Token { get; set; } [JsonIgnore] public byte[] SignatureBytes => Convert.FromBase64String(Signature); @@ -84,6 +87,7 @@ public class UserLicense : ILicense !p.Name.Equals(nameof(Signature)) && !p.Name.Equals(nameof(SignatureBytes)) && !p.Name.Equals(nameof(LicenseType)) && + !p.Name.Equals(nameof(Token)) && ( !forHash || ( @@ -113,8 +117,47 @@ public class UserLicense : ILicense } } - public bool CanUse(User user, out string exception) + public bool CanUse(User user, ClaimsPrincipal claimsPrincipal, out string exception) { + if (string.IsNullOrWhiteSpace(Token) || claimsPrincipal is null) + { + return ObsoleteCanUse(user, out exception); + } + + var errorMessages = new StringBuilder(); + + if (!user.EmailVerified) + { + errorMessages.AppendLine("The user's email is not verified."); + } + + var email = claimsPrincipal.GetValue(nameof(Email)); + if (!email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase)) + { + errorMessages.AppendLine("The user's email does not match the license email."); + } + + if (errorMessages.Length > 0) + { + exception = $"Invalid license. {errorMessages.ToString().TrimEnd()}"; + return false; + } + + exception = ""; + return true; + } + + /// + /// Do not extend this method. It is only here for backwards compatibility with old licenses. + /// Instead, extend the CanUse method using the ClaimsPrincipal. + /// + /// + /// + /// + /// + private bool ObsoleteCanUse(User user, out string exception) + { + // Do not extend this method. It is only here for backwards compatibility with old licenses. var errorMessages = new StringBuilder(); if (Issued > DateTime.UtcNow) @@ -152,22 +195,46 @@ public class UserLicense : ILicense return true; } - public bool VerifyData(User user) + public bool VerifyData(User user, ClaimsPrincipal claimsPrincipal) { + if (string.IsNullOrWhiteSpace(Token) || claimsPrincipal is null) + { + return ObsoleteVerifyData(user); + } + + var licenseKey = claimsPrincipal.GetValue(nameof(LicenseKey)); + var premium = claimsPrincipal.GetValue(nameof(Premium)); + var email = claimsPrincipal.GetValue(nameof(Email)); + + return licenseKey == user.LicenseKey && + premium == user.Premium && + email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase); + } + + /// + /// Do not extend this method. It is only here for backwards compatibility with old licenses. + /// Instead, extend the VerifyData method using the ClaimsPrincipal. + /// + /// + /// + /// + private bool ObsoleteVerifyData(User user) + { + // Do not extend this method. It is only here for backwards compatibility with old licenses. if (Issued > DateTime.UtcNow || Expires < DateTime.UtcNow) { return false; } - if (Version == 1) + if (Version != 1) { - return - user.LicenseKey != null && user.LicenseKey.Equals(LicenseKey) && - user.Premium == Premium && - user.Email.Equals(Email, StringComparison.InvariantCultureIgnoreCase); + throw new NotSupportedException($"Version {Version} is not supported."); } - throw new NotSupportedException($"Version {Version} is not supported."); + return + user.LicenseKey != null && user.LicenseKey.Equals(LicenseKey) && + user.Premium == Premium && + user.Email.Equals(Email, StringComparison.InvariantCultureIgnoreCase); } public bool VerifySignature(X509Certificate2 certificate) diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs b/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs index b8fad451e2..a4b08736c2 100644 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs +++ b/src/Core/OrganizationFeatures/OrganizationLicenses/Cloud/CloudGetOrganizationLicenseQuery.cs @@ -33,6 +33,10 @@ public class CloudGetOrganizationLicenseQuery : ICloudGetOrganizationLicenseQuer } var subscriptionInfo = await _paymentService.GetSubscriptionAsync(organization); - return new OrganizationLicense(organization, subscriptionInfo, installationId, _licensingService, version); + + return new OrganizationLicense(organization, subscriptionInfo, installationId, _licensingService, version) + { + Token = await _licensingService.CreateOrganizationTokenAsync(organization, installationId, subscriptionInfo) + }; } } diff --git a/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs b/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs index 1f8c6604b8..ffeee39c07 100644 --- a/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommand.cs @@ -39,7 +39,8 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman throw new BadRequestException("License is already in use by another organization."); } - var canUse = license.CanUse(_globalSettings, _licensingService, out var exception) && + var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license); + var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) && selfHostedOrganization.CanUseLicense(license, out exception); if (!canUse) diff --git a/src/Core/Services/ILicensingService.cs b/src/Core/Services/ILicensingService.cs index e92fa87fd6..7301f7c689 100644 --- a/src/Core/Services/ILicensingService.cs +++ b/src/Core/Services/ILicensingService.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using System.Security.Claims; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Models.Business; @@ -13,5 +14,12 @@ public interface ILicensingService byte[] SignLicense(ILicense license); Task ReadOrganizationLicenseAsync(Organization organization); Task ReadOrganizationLicenseAsync(Guid organizationId); + ClaimsPrincipal GetClaimsPrincipalFromLicense(ILicense license); + Task CreateOrganizationTokenAsync( + Organization organization, + Guid installationId, + SubscriptionInfo subscriptionInfo); + + Task CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo); } diff --git a/src/Core/Services/Implementations/LicensingService.cs b/src/Core/Services/Implementations/LicensingService.cs index 85b8f31200..866f0bb6e1 100644 --- a/src/Core/Services/Implementations/LicensingService.cs +++ b/src/Core/Services/Implementations/LicensingService.cs @@ -1,15 +1,22 @@ -using System.Security.Cryptography.X509Certificates; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Licenses.Models; +using Bit.Core.Billing.Licenses.Services; using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; +using IdentityModel; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; namespace Bit.Core.Services; @@ -19,27 +26,33 @@ public class LicensingService : ILicensingService private readonly IGlobalSettings _globalSettings; private readonly IUserRepository _userRepository; private readonly IOrganizationRepository _organizationRepository; - private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IMailService _mailService; private readonly ILogger _logger; + private readonly ILicenseClaimsFactory _organizationLicenseClaimsFactory; + private readonly ILicenseClaimsFactory _userLicenseClaimsFactory; + private readonly IFeatureService _featureService; private IDictionary _userCheckCache = new Dictionary(); public LicensingService( IUserRepository userRepository, IOrganizationRepository organizationRepository, - IOrganizationUserRepository organizationUserRepository, IMailService mailService, IWebHostEnvironment environment, ILogger logger, - IGlobalSettings globalSettings) + IGlobalSettings globalSettings, + ILicenseClaimsFactory organizationLicenseClaimsFactory, + IFeatureService featureService, + ILicenseClaimsFactory userLicenseClaimsFactory) { _userRepository = userRepository; _organizationRepository = organizationRepository; - _organizationUserRepository = organizationUserRepository; _mailService = mailService; _logger = logger; _globalSettings = globalSettings; + _organizationLicenseClaimsFactory = organizationLicenseClaimsFactory; + _featureService = featureService; + _userLicenseClaimsFactory = userLicenseClaimsFactory; var certThumbprint = environment.IsDevelopment() ? "207E64A231E8AA32AAF68A61037C075EBEBD553F" : @@ -104,13 +117,13 @@ public class LicensingService : ILicensingService continue; } - if (!license.VerifyData(org, _globalSettings)) + if (!license.VerifyData(org, GetClaimsPrincipalFromLicense(license), _globalSettings)) { await DisableOrganizationAsync(org, license, "Invalid data."); continue; } - if (!license.VerifySignature(_certificate)) + if (string.IsNullOrWhiteSpace(license.Token) && !license.VerifySignature(_certificate)) { await DisableOrganizationAsync(org, license, "Invalid signature."); continue; @@ -203,13 +216,14 @@ public class LicensingService : ILicensingService return false; } - if (!license.VerifyData(user)) + var claimsPrincipal = GetClaimsPrincipalFromLicense(license); + if (!license.VerifyData(user, claimsPrincipal)) { await DisablePremiumAsync(user, license, "Invalid data."); return false; } - if (!license.VerifySignature(_certificate)) + if (string.IsNullOrWhiteSpace(license.Token) && !license.VerifySignature(_certificate)) { await DisablePremiumAsync(user, license, "Invalid signature."); return false; @@ -234,7 +248,21 @@ public class LicensingService : ILicensingService public bool VerifyLicense(ILicense license) { - return license.VerifySignature(_certificate); + if (string.IsNullOrWhiteSpace(license.Token)) + { + return license.VerifySignature(_certificate); + } + + try + { + _ = GetClaimsPrincipalFromLicense(license); + return true; + } + catch (Exception e) + { + _logger.LogWarning(e, "Invalid token."); + return false; + } } public byte[] SignLicense(ILicense license) @@ -272,4 +300,101 @@ public class LicensingService : ILicensingService using var fs = File.OpenRead(filePath); return await JsonSerializer.DeserializeAsync(fs); } + + public ClaimsPrincipal GetClaimsPrincipalFromLicense(ILicense license) + { + if (string.IsNullOrWhiteSpace(license.Token)) + { + return null; + } + + var audience = license switch + { + OrganizationLicense orgLicense => $"organization:{orgLicense.Id}", + UserLicense userLicense => $"user:{userLicense.Id}", + _ => throw new ArgumentException("Unsupported license type.", nameof(license)), + }; + + var token = license.Token; + var tokenHandler = new JwtSecurityTokenHandler(); + var validationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new X509SecurityKey(_certificate), + ValidateIssuer = true, + ValidIssuer = "bitwarden", + ValidateAudience = true, + ValidAudience = audience, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero, + RequireExpirationTime = true + }; + + try + { + return tokenHandler.ValidateToken(token, validationParameters, out _); + } + catch (Exception ex) + { + // Token exceptions thrown are interpreted by the client as Identity errors and cause the user to logout + // Mask them by rethrowing as BadRequestException + throw new BadRequestException($"Invalid license. {ex.Message}"); + } + } + + public async Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.SelfHostLicenseRefactor)) + { + return null; + } + + var licenseContext = new LicenseContext + { + InstallationId = installationId, + SubscriptionInfo = subscriptionInfo, + }; + + var claims = await _organizationLicenseClaimsFactory.GenerateClaims(organization, licenseContext); + var audience = $"organization:{organization.Id}"; + + return GenerateToken(claims, audience); + } + + public async Task CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.SelfHostLicenseRefactor)) + { + return null; + } + + var licenseContext = new LicenseContext { SubscriptionInfo = subscriptionInfo }; + var claims = await _userLicenseClaimsFactory.GenerateClaims(user, licenseContext); + var audience = $"user:{user.Id}"; + + return GenerateToken(claims, audience); + } + + private string GenerateToken(List claims, string audience) + { + if (claims.All(claim => claim.Type != JwtClaimTypes.JwtId)) + { + claims.Add(new Claim(JwtClaimTypes.JwtId, Guid.NewGuid().ToString())); + } + + var securityKey = new RsaSecurityKey(_certificate.GetRSAPrivateKey()); + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Issuer = "bitwarden", + Audience = audience, + NotBefore = DateTime.UtcNow, + Expires = DateTime.UtcNow.AddYears(1), // Org expiration is a claim + SigningCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.RsaSha256Signature) + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var token = tokenHandler.CreateToken(tokenDescriptor); + return tokenHandler.WriteToken(token); + } } diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 2199d0a7af..fa8cd3cef8 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -908,7 +908,9 @@ public class UserService : UserManager, IUserService, IDisposable throw new BadRequestException("Invalid license."); } - if (!license.CanUse(user, out var exceptionMessage)) + var claimsPrincipal = _licenseService.GetClaimsPrincipalFromLicense(license); + + if (!license.CanUse(user, claimsPrincipal, out var exceptionMessage)) { throw new BadRequestException(exceptionMessage); } @@ -987,7 +989,9 @@ public class UserService : UserManager, IUserService, IDisposable throw new BadRequestException("Invalid license."); } - if (!license.CanUse(user, out var exceptionMessage)) + var claimsPrincipal = _licenseService.GetClaimsPrincipalFromLicense(license); + + if (!license.CanUse(user, claimsPrincipal, out var exceptionMessage)) { throw new BadRequestException(exceptionMessage); } @@ -1111,7 +1115,9 @@ public class UserService : UserManager, IUserService, IDisposable } } - public async Task GenerateLicenseAsync(User user, SubscriptionInfo subscriptionInfo = null, + public async Task GenerateLicenseAsync( + User user, + SubscriptionInfo subscriptionInfo = null, int? version = null) { if (user == null) @@ -1124,8 +1130,13 @@ public class UserService : UserManager, IUserService, IDisposable subscriptionInfo = await _paymentService.GetSubscriptionAsync(user); } - return subscriptionInfo == null ? new UserLicense(user, _licenseService) : - new UserLicense(user, subscriptionInfo, _licenseService); + var userLicense = subscriptionInfo == null + ? new UserLicense(user, _licenseService) + : new UserLicense(user, subscriptionInfo, _licenseService); + + userLicense.Token = await _licenseService.CreateUserTokenAsync(user, subscriptionInfo); + + return userLicense; } public override async Task CheckPasswordAsync(User user, string password) diff --git a/src/Core/Services/NoopImplementations/NoopLicensingService.cs b/src/Core/Services/NoopImplementations/NoopLicensingService.cs index 8eb42a318c..dc733e9a33 100644 --- a/src/Core/Services/NoopImplementations/NoopLicensingService.cs +++ b/src/Core/Services/NoopImplementations/NoopLicensingService.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using System.Security.Claims; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Models.Business; using Bit.Core.Settings; @@ -53,4 +54,19 @@ public class NoopLicensingService : ILicensingService { return Task.FromResult(null); } + + public ClaimsPrincipal GetClaimsPrincipalFromLicense(ILicense license) + { + return null; + } + + public Task CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo) + { + return Task.FromResult(null); + } + + public Task CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo) + { + return Task.FromResult(null); + } } diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index ab4108bfef..7585739d82 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -230,7 +230,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddScoped(); services.AddSingleton(_ => { var options = new LookupClientOptions { Timeout = TimeSpan.FromSeconds(15), UseTcpOnly = true }; diff --git a/test/Core.Test/Models/Business/OrganizationLicenseTests.cs b/test/Core.Test/Models/Business/OrganizationLicenseTests.cs index c2eb0dd934..26945f533e 100644 --- a/test/Core.Test/Models/Business/OrganizationLicenseTests.cs +++ b/test/Core.Test/Models/Business/OrganizationLicenseTests.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Security.Claims; +using System.Text.Json; using Bit.Core.Models.Business; using Bit.Core.Services; using Bit.Core.Settings; @@ -36,7 +37,7 @@ public class OrganizationLicenseTests [Theory] [BitAutoData(OrganizationLicense.CurrentLicenseFileVersion)] // Previous version (this property is 1 behind) [BitAutoData(OrganizationLicense.CurrentLicenseFileVersion + 1)] // Current version - public void OrganizationLicense_LoadedFromDisk_VerifyData_Passes(int licenseVersion) + public void OrganizationLicense_LoadedFromDisk_VerifyData_Passes(int licenseVersion, ClaimsPrincipal claimsPrincipal) { var license = OrganizationLicenseFileFixtures.GetVersion(licenseVersion); @@ -49,7 +50,7 @@ public class OrganizationLicenseTests { Id = new Guid(OrganizationLicenseFileFixtures.InstallationId) }); - Assert.True(license.VerifyData(organization, globalSettings)); + Assert.True(license.VerifyData(organization, claimsPrincipal, globalSettings)); } /// diff --git a/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs index 565f2f32c4..b8e677177c 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using System.Security.Claims; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations; @@ -48,6 +49,9 @@ public class UpdateOrganizationLicenseCommandTests license.InstallationId = globalSettings.Installation.Id; license.LicenseType = LicenseType.Organization; sutProvider.GetDependency().VerifyLicense(license).Returns(true); + sutProvider.GetDependency() + .GetClaimsPrincipalFromLicense(license) + .Returns((ClaimsPrincipal)null); // Passing values for SelfHostedOrganizationDetails.CanUseLicense // NSubstitute cannot override non-virtual members so we have to ensure the real method passes @@ -79,10 +83,11 @@ public class UpdateOrganizationLicenseCommandTests .Received(1) .ReplaceAndUpdateCacheAsync(Arg.Is( org => AssertPropertyEqual(license, org, - "Id", "MaxStorageGb", "Issued", "Refresh", "Version", "Trial", "LicenseType", - "Hash", "Signature", "SignatureBytes", "InstallationId", "Expires", "ExpirationWithoutGracePeriod") && - // Same property but different name, use explicit mapping - org.ExpirationDate == license.Expires)); + "Id", "MaxStorageGb", "Issued", "Refresh", "Version", "Trial", "LicenseType", + "Hash", "Signature", "SignatureBytes", "InstallationId", "Expires", + "ExpirationWithoutGracePeriod", "Token") && + // Same property but different name, use explicit mapping + org.ExpirationDate == license.Expires)); } finally { diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index aa2c0a5cc9..71cceb86ad 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Security.Claims; +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; @@ -62,6 +63,9 @@ public class UserServiceTests sutProvider.GetDependency() .VerifyLicense(userLicense) .Returns(true); + sutProvider.GetDependency() + .GetClaimsPrincipalFromLicense(userLicense) + .Returns((ClaimsPrincipal)null); await sutProvider.Sut.UpdateLicenseAsync(user, userLicense); From 04f9d7dd8e952d0820cbf3a0784b398fd36562f7 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Thu, 5 Dec 2024 09:40:55 -0500 Subject: [PATCH 22/30] Remove SM team from CODEOWNERS (#5117) --- .github/CODEOWNERS | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 47d3525def..1d142016f2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -38,7 +38,6 @@ src/Identity @bitwarden/team-auth-dev # Key Management team **/KeyManagement @bitwarden/team-key-management-dev -**/SecretsManager @bitwarden/team-secrets-manager-dev **/Tools @bitwarden/team-tools-dev # Vault team From f471fffe42a2a5c73448c720f36255354091a1ea Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 5 Dec 2024 08:59:35 -0600 Subject: [PATCH 23/30] [PM-10317] Email Users For Org Claiming Domain (#5094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revoking users when enabling single org and 2fa policies. Fixing tests. * Added migration. * Wrote tests and fixed bugs found. * Patch build process * Fixing tests. * Added unit test around disabling the feature flag. * Updated error message to be public and added test for validating the request. * formatting * Added some tests for single org policy validator. * Fix issues from merge. * Added sending emails to revoked non-compliant users. * Fixing name. Adding two factor policy email. * Send email when user has been revoked. * Correcting migration name. * Fixing templates and logic issue in Revoke command. * Moving interface into its own file. * Correcting namespaces for email templates. * correcting logic that would not allow normal users to revoke non owners. * Actually correcting the test and logic. * dotnet format. Added exec to bottom of bulk sproc * Update src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeNonCompliantOrganizationUserCommand.cs Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> * Updated OrgIds to be a json string * Fixing errors. * Updating test * Moving command result. * Formatting and request rename * Realized this would throw a null error from the system domain verification. Adding unknown type to event system user. Adding optional parameter to SaveAsync in policy service in order to pass in event system user. * Code review changes * Removing todos * Corrected test name. * Syncing filename to record name. * Fixing up the tests. * Added happy path test * Naming corrections. And corrected EF query. * added check against event service * Code review changes. * Fixing tests. * splitting up tests * Added templates and email side effect for claiming a domain. * bringing changes from nc user changes. * Switched to enqueue mail message. * Filled in DomainClaimedByOrganization.html.hbs * Added text document for domain claiming * Fixing migration script. * Remove old sproc * Limiting sending of the email down to users who are a part of the domain being claimed. * Added test for change * Renames and fixed up email. * Fixing up CSS --------- Co-authored-by: Matt Bishop Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> Co-authored-by: Rui Tome --- .../VerifyOrganizationDomainCommand.cs | 36 ++++++++++--- .../DomainClaimedByOrganization.html.hbs | 24 +++++++++ .../DomainClaimedByOrganization.text.hbs | 8 +++ .../ManagedUserDomainClaimedEmails.cs | 5 ++ .../ClaimedDomainUserNotificationViewModel.cs | 6 +++ src/Core/Services/IMailService.cs | 2 + .../Implementations/HandlebarsMailService.cs | 17 ++++++ .../NoopImplementations/NoopMailService.cs | 2 + .../VerifyOrganizationDomainCommandTests.cs | 54 ++++++++++++++++++- 9 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 src/Core/MailTemplates/Handlebars/AdminConsole/DomainClaimedByOrganization.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/AdminConsole/DomainClaimedByOrganization.text.hbs create mode 100644 src/Core/Models/Data/Organizations/ManagedUserDomainClaimedEmails.cs create mode 100644 src/Core/Models/Mail/ClaimedDomainUserNotificationViewModel.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index 375c6326ef..e011819f0f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -7,6 +7,7 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -22,11 +23,12 @@ public class VerifyOrganizationDomainCommand( IFeatureService featureService, ICurrentContext currentContext, ISavePolicyCommand savePolicyCommand, + IMailService mailService, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, ILogger logger) : IVerifyOrganizationDomainCommand { - - public async Task UserVerifyOrganizationDomainAsync(OrganizationDomain organizationDomain) { if (currentContext.UserId is null) @@ -109,7 +111,7 @@ public class VerifyOrganizationDomainCommand( { domain.SetVerifiedDate(); - await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser); + await DomainVerificationSideEffectsAsync(domain, actingUser); } } catch (Exception e) @@ -121,19 +123,37 @@ public class VerifyOrganizationDomainCommand( return domain; } - private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser) + private async Task DomainVerificationSideEffectsAsync(OrganizationDomain domain, IActingUser actingUser) { if (featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)) { - var policyUpdate = new PolicyUpdate + await EnableSingleOrganizationPolicyAsync(domain.OrganizationId, actingUser); + 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 - }; + }); - await savePolicyCommand.SaveAsync(policyUpdate); - } + private async Task SendVerifiedDomainUserEmailAsync(OrganizationDomain domain) + { + var orgUserUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(domain.OrganizationId); + + var domainUserEmails = orgUserUsers + .Where(ou => ou.Email.ToLower().EndsWith($"@{domain.DomainName.ToLower()}") && + ou.Status != OrganizationUserStatusType.Revoked && + ou.Status != OrganizationUserStatusType.Invited) + .Select(ou => ou.Email); + + var organization = await organizationRepository.GetByIdAsync(domain.OrganizationId); + + await mailService.SendClaimedDomainUserEmailAsync(new ManagedUserDomainClaimedEmails(domainUserEmails, organization)); } } diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/DomainClaimedByOrganization.html.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/DomainClaimedByOrganization.html.hbs new file mode 100644 index 0000000000..05ca170a50 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/DomainClaimedByOrganization.html.hbs @@ -0,0 +1,24 @@ +{{#>TitleContactUsHtmlLayout}} + + + + + + + + + + +
+ As a member of {{OrganizationName}}, your Bitwarden account is claimed and owned by your organization. +
+ Here's what that means: +
    +
  • This account should only be used to store items related to {{OrganizationName}}
  • +
  • Admins managing your Bitwarden organization manage your email address and other account settings
  • +
  • Admins can also revoke or delete your account at any time
  • +
+
+ For more information, please refer to the following help article: Claimed Accounts +
+{{/TitleContactUsHtmlLayout}} diff --git a/src/Core/MailTemplates/Handlebars/AdminConsole/DomainClaimedByOrganization.text.hbs b/src/Core/MailTemplates/Handlebars/AdminConsole/DomainClaimedByOrganization.text.hbs new file mode 100644 index 0000000000..c0078d389d --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/AdminConsole/DomainClaimedByOrganization.text.hbs @@ -0,0 +1,8 @@ +As a member of {{OrganizationName}}, your Bitwarden account is claimed and owned by your organization. + +Here's what that means: +- This account should only be used to store items related to {{OrganizationName}} +- Your admins managing your Bitwarden organization manages your email address and other account settings +- Your admins can also revoke or delete your account at any time + +For more information, please refer to the following help article: Claimed Accounts (https://bitwarden.com/help/claimed-accounts) diff --git a/src/Core/Models/Data/Organizations/ManagedUserDomainClaimedEmails.cs b/src/Core/Models/Data/Organizations/ManagedUserDomainClaimedEmails.cs new file mode 100644 index 0000000000..429257e266 --- /dev/null +++ b/src/Core/Models/Data/Organizations/ManagedUserDomainClaimedEmails.cs @@ -0,0 +1,5 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.Models.Data.Organizations; + +public record ManagedUserDomainClaimedEmails(IEnumerable EmailList, Organization Organization); diff --git a/src/Core/Models/Mail/ClaimedDomainUserNotificationViewModel.cs b/src/Core/Models/Mail/ClaimedDomainUserNotificationViewModel.cs new file mode 100644 index 0000000000..97591b51bc --- /dev/null +++ b/src/Core/Models/Mail/ClaimedDomainUserNotificationViewModel.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Models.Mail; + +public class ClaimedDomainUserNotificationViewModel : BaseTitleContactUsMailModel +{ + public string OrganizationName { get; init; } +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index bc8d1440f1..c6c9dc7948 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Entities; +using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Mail; namespace Bit.Core.Services; @@ -93,5 +94,6 @@ public interface IMailService Task SendRequestSMAccessToAdminEmailAsync(IEnumerable adminEmails, string organizationName, string userRequestingAccess, string emailContent); Task SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(string email, string offerAcceptanceDate, string organizationId, string organizationName); + Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList); } diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index acc729e53c..c220df18a1 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -7,6 +7,7 @@ using Bit.Core.Auth.Models.Mail; using Bit.Core.Billing.Enums; 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.FamiliesForEnterprise; using Bit.Core.Models.Mail.Provider; @@ -460,6 +461,22 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList) + { + await EnqueueMailAsync(emailList.EmailList.Select(email => + CreateMessage(email, emailList.Organization))); + return; + + MailQueueMessage CreateMessage(string emailAddress, Organization org) => + new(CreateDefaultMessage($"Your Bitwarden account is claimed by {org.DisplayName()}", emailAddress), + "AdminConsole.DomainClaimedByOrganization", + new ClaimedDomainUserNotificationViewModel + { + TitleFirst = $"Hey {emailAddress}, here is a heads up on your claimed account:", + OrganizationName = CoreHelpers.SanitizeForEmail(org.DisplayName(), false) + }); + } + public async Task SendNewDeviceLoggedInEmail(string email, string deviceType, DateTime timestamp, string ip) { var message = CreateDefaultMessage($"New Device Logged In From {deviceType}", email); diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 399874eee7..e8ea8d9863 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Entities; +using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Mail; namespace Bit.Core.Services; @@ -309,5 +310,6 @@ public class NoopMailService : IMailService { return Task.FromResult(0); } + public Task SendClaimedDomainUserEmailAsync(ManagedUserDomainClaimedEmails emailList) => Task.CompletedTask; } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs index 700df88d54..6c6d0e35f0 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -7,6 +8,8 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; @@ -269,4 +272,53 @@ public class VerifyOrganizationDomainCommandTests .DidNotReceive() .SaveAsync(Arg.Any()); } + + [Theory, BitAutoData] + public async Task UserVerifyOrganizationDomainAsync_GivenOrganizationDomainWithAccountDeprovisioningEnabled_WhenDomainIsVerified_ThenEmailShouldBeSentToUsersWhoBelongToTheDomain( + ICollection organizationUsers, + OrganizationDomain domain, + Organization organization, + SutProvider sutProvider) + { + foreach (var organizationUser in organizationUsers) + { + organizationUser.Email = $"{organizationUser.Name}@{domain.DomainName}"; + } + + var mockedUsers = organizationUsers + .Where(x => x.Status != OrganizationUserStatusType.Invited && + x.Status != OrganizationUserStatusType.Revoked).ToList(); + + organization.Id = domain.OrganizationId; + + sutProvider.GetDependency() + .GetClaimedDomainsByDomainNameAsync(domain.DomainName) + .Returns([]); + + sutProvider.GetDependency() + .GetByIdAsync(domain.OrganizationId) + .Returns(organization); + + sutProvider.GetDependency() + .ResolveAsync(domain.DomainName, domain.Txt) + .Returns(true); + + sutProvider.GetDependency() + .UserId.Returns(Guid.NewGuid()); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccountDeprovisioning) + .Returns(true); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(domain.OrganizationId) + .Returns(mockedUsers); + + _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); + + await sutProvider.GetDependency().Received().SendClaimedDomainUserEmailAsync( + Arg.Is(x => + x.EmailList.Count(e => e.EndsWith(domain.DomainName)) == mockedUsers.Count && + x.Organization.Id == organization.Id)); + } } From 1f1510f4d48f68adb9c6f1babba8bef44293bfee Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Thu, 5 Dec 2024 10:46:01 -0600 Subject: [PATCH 24/30] PM-15091 Add Feature Flag to DB called UseRiskInsights (#5088) Add a new column called UseRiskInsights to `dbo.Organization` --- .../Controllers/OrganizationsController.cs | 1 + .../Models/OrganizationEditModel.cs | 4 + .../Models/OrganizationViewModel.cs | 1 + .../Views/Shared/_OrganizationForm.cshtml | 13 +- .../OrganizationResponseModel.cs | 2 + .../ProfileOrganizationResponseModel.cs | 2 + ...rofileProviderOrganizationResponseModel.cs | 1 + .../AdminConsole/Entities/Organization.cs | 5 + .../Data/Organizations/OrganizationAbility.cs | 2 + .../OrganizationUserOrganizationDetails.cs | 1 + .../ProviderUserOrganizationDetails.cs | 1 + .../Repositories/OrganizationRepository.cs | 3 +- ...izationUserOrganizationDetailsViewQuery.cs | 1 + ...roviderUserOrganizationDetailsViewQuery.cs | 1 + .../Stored Procedures/Organization_Create.sql | 9 +- .../Organization_ReadAbilities.sql | 3 +- .../Stored Procedures/Organization_Update.sql | 6 +- src/Sql/dbo/Tables/Organization.sql | 1 + ...rganizationUserOrganizationDetailsView.sql | 3 +- ...derUserProviderOrganizationDetailsView.sql | 3 +- .../OrganizationUserRepositoryTests.cs | 1 + ...25_00_AddUseRiskInsightsToOrganization.sql | 356 ++ ...024-11-25_01_AddUseRiskInsightsToViews.sql | 142 + ...5185627_AddUseRiskInsightsFlag.Designer.cs | 2943 ++++++++++++++++ .../20241125185627_AddUseRiskInsightsFlag.cs | 28 + .../DatabaseContextModelSnapshot.cs | 3 + ...5185635_AddUseRiskInsightsFlag.Designer.cs | 2949 +++++++++++++++++ .../20241125185635_AddUseRiskInsightsFlag.cs | 28 + .../DatabaseContextModelSnapshot.cs | 3 + ...5185632_AddUseRiskInsightsFlag.Designer.cs | 2932 ++++++++++++++++ .../20241125185632_AddUseRiskInsightsFlag.cs | 28 + .../DatabaseContextModelSnapshot.cs | 3 + 32 files changed, 9467 insertions(+), 12 deletions(-) create mode 100644 util/Migrator/DbScripts/2024-11-25_00_AddUseRiskInsightsToOrganization.sql create mode 100644 util/Migrator/DbScripts/2024-11-25_01_AddUseRiskInsightsToViews.sql create mode 100644 util/MySqlMigrations/Migrations/20241125185627_AddUseRiskInsightsFlag.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20241125185627_AddUseRiskInsightsFlag.cs create mode 100644 util/PostgresMigrations/Migrations/20241125185635_AddUseRiskInsightsFlag.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20241125185635_AddUseRiskInsightsFlag.cs create mode 100644 util/SqliteMigrations/Migrations/20241125185632_AddUseRiskInsightsFlag.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20241125185632_AddUseRiskInsightsFlag.cs diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 67a80a3754..4c4df3d15b 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -448,6 +448,7 @@ public class OrganizationsController : Controller organization.UseTotp = model.UseTotp; organization.UsersGetPremium = model.UsersGetPremium; organization.UseSecretsManager = model.UseSecretsManager; + organization.UseRiskInsights = model.UseRiskInsights; //secrets organization.SmSeats = model.SmSeats; diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index 48340df708..be191ddb8d 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -80,6 +80,7 @@ public class OrganizationEditModel : OrganizationViewModel Use2fa = org.Use2fa; UseApi = org.UseApi; UseSecretsManager = org.UseSecretsManager; + UseRiskInsights = org.UseRiskInsights; UseResetPassword = org.UseResetPassword; SelfHost = org.SelfHost; UsersGetPremium = org.UsersGetPremium; @@ -144,6 +145,8 @@ public class OrganizationEditModel : OrganizationViewModel public bool UseScim { get; set; } [Display(Name = "Secrets Manager")] public new bool UseSecretsManager { get; set; } + [Display(Name = "Risk Insights")] + public new bool UseRiskInsights { get; set; } [Display(Name = "Self Host")] public bool SelfHost { get; set; } [Display(Name = "Users Get Premium")] @@ -284,6 +287,7 @@ public class OrganizationEditModel : OrganizationViewModel existingOrganization.Use2fa = Use2fa; existingOrganization.UseApi = UseApi; existingOrganization.UseSecretsManager = UseSecretsManager; + existingOrganization.UseRiskInsights = UseRiskInsights; existingOrganization.UseResetPassword = UseResetPassword; existingOrganization.SelfHost = SelfHost; existingOrganization.UsersGetPremium = UsersGetPremium; diff --git a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs index b58d3aa52e..69486bdcd2 100644 --- a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs @@ -69,4 +69,5 @@ public class OrganizationViewModel public int ServiceAccountsCount { get; set; } public int OccupiedSmSeatsCount { get; set; } public bool UseSecretsManager => Organization.UseSecretsManager; + public bool UseRiskInsights => Organization.UseRiskInsights; } diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index 5187b6690a..23d2057d07 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -94,7 +94,7 @@

Features

-
+

General

@@ -146,7 +146,7 @@
-
+

Password Manager

@@ -157,13 +157,20 @@
-
+

Secrets Manager

+
+

Access Insights

+
+ + +
+
} diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 7808b564a8..908a3a9385 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -60,6 +60,7 @@ public class OrganizationResponseModel : ResponseModel // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; + UseRiskInsights = organization.UseRiskInsights; } public Guid Id { get; set; } @@ -106,6 +107,7 @@ public class OrganizationResponseModel : ResponseModel // Deperectated: https://bitwarden.atlassian.net/browse/PM-10863 public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } + public bool UseRiskInsights { get; set; } } public class OrganizationSubscriptionResponseModel : OrganizationResponseModel diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index 1fcaba5f93..96b86de164 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -71,6 +71,7 @@ public class ProfileOrganizationResponseModel : ResponseModel LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; UserIsManagedByOrganization = organizationIdsManagingUser.Contains(organization.OrganizationId); + UseRiskInsights = organization.UseRiskInsights; if (organization.SsoConfig != null) { @@ -143,4 +144,5 @@ public class ProfileOrganizationResponseModel : ResponseModel /// False if the Account Deprovisioning feature flag is disabled. /// public bool UserIsManagedByOrganization { get; set; } + public bool UseRiskInsights { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs index 92498834db..4ec86a29a4 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs @@ -49,5 +49,6 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo // https://bitwarden.atlassian.net/browse/PM-10863 LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; + UseRiskInsights = organization.UseRiskInsights; } } diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index c556dfe601..720a77d6a4 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -115,6 +115,11 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, ///
public bool AllowAdminAccessToAllCollectionItems { get; set; } + /// + /// Risk Insights is a reporting feature that provides insights into the security of an organization's vault. + /// + public bool UseRiskInsights { get; set; } + public void SetNewId() { if (Id == default(Guid)) diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs index a91b960839..0da0928dbe 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs @@ -26,6 +26,7 @@ public class OrganizationAbility // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; + UseRiskInsights = organization.UseRiskInsights; } public Guid Id { get; set; } @@ -45,4 +46,5 @@ public class OrganizationAbility // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } + public bool UseRiskInsights { 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 435369e77a..7b9ea971d3 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -59,4 +59,5 @@ public class OrganizationUserOrganizationDetails // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } + public bool UseRiskInsights { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index a2ac622539..c2880b543f 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -44,4 +44,5 @@ public class ProviderUserOrganizationDetails public bool LimitCollectionDeletion { get; set; } public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } + public bool UseRiskInsights { get; set; } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index b3ee254889..7a667db8f5 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -103,7 +103,8 @@ public class OrganizationRepository : Repository +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("20241125185627_AddUseRiskInsightsFlag")] + partial class AddUseRiskInsightsFlag + { + /// + 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("LimitCollectionCreationDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("LimitCollectionDeletion") + .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("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("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.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.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.HasKey("Id"); + + b.ToTable("Installation", (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.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") + .HasColumnType("longtext"); + + 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("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("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.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("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.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.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + 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/20241125185627_AddUseRiskInsightsFlag.cs b/util/MySqlMigrations/Migrations/20241125185627_AddUseRiskInsightsFlag.cs new file mode 100644 index 0000000000..7036c9aaae --- /dev/null +++ b/util/MySqlMigrations/Migrations/20241125185627_AddUseRiskInsightsFlag.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class AddUseRiskInsightsFlag : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UseRiskInsights", + table: "Organization", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UseRiskInsights", + table: "Organization"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 36c46f629f..5927762791 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -191,6 +191,9 @@ namespace Bit.MySqlMigrations.Migrations b.Property("UseResetPassword") .HasColumnType("tinyint(1)"); + b.Property("UseRiskInsights") + .HasColumnType("tinyint(1)"); + b.Property("UseScim") .HasColumnType("tinyint(1)"); diff --git a/util/PostgresMigrations/Migrations/20241125185635_AddUseRiskInsightsFlag.Designer.cs b/util/PostgresMigrations/Migrations/20241125185635_AddUseRiskInsightsFlag.Designer.cs new file mode 100644 index 0000000000..895a4765d8 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20241125185635_AddUseRiskInsightsFlag.Designer.cs @@ -0,0 +1,2949 @@ +// +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("20241125185635_AddUseRiskInsightsFlag")] + partial class AddUseRiskInsightsFlag + { + /// + 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("LimitCollectionCreationDeletion") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .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("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("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.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.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.HasKey("Id"); + + b.ToTable("Installation", (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.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") + .HasColumnType("text"); + + 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("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("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.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("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.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.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + 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/20241125185635_AddUseRiskInsightsFlag.cs b/util/PostgresMigrations/Migrations/20241125185635_AddUseRiskInsightsFlag.cs new file mode 100644 index 0000000000..36d7c77e44 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20241125185635_AddUseRiskInsightsFlag.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class AddUseRiskInsightsFlag : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UseRiskInsights", + table: "Organization", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UseRiskInsights", + table: "Organization"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index 69c9dae160..4259d1aed8 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -193,6 +193,9 @@ namespace Bit.PostgresMigrations.Migrations b.Property("UseResetPassword") .HasColumnType("boolean"); + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + b.Property("UseScim") .HasColumnType("boolean"); diff --git a/util/SqliteMigrations/Migrations/20241125185632_AddUseRiskInsightsFlag.Designer.cs b/util/SqliteMigrations/Migrations/20241125185632_AddUseRiskInsightsFlag.Designer.cs new file mode 100644 index 0000000000..9120ba9715 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20241125185632_AddUseRiskInsightsFlag.Designer.cs @@ -0,0 +1,2932 @@ +// +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("20241125185632_AddUseRiskInsightsFlag")] + partial class AddUseRiskInsightsFlag + { + /// + 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("LimitCollectionCreationDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .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("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("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.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.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.HasKey("Id"); + + b.ToTable("Installation", (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.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") + .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("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("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.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("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.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.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + 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/20241125185632_AddUseRiskInsightsFlag.cs b/util/SqliteMigrations/Migrations/20241125185632_AddUseRiskInsightsFlag.cs new file mode 100644 index 0000000000..86ff055fc5 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20241125185632_AddUseRiskInsightsFlag.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class AddUseRiskInsightsFlag : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UseRiskInsights", + table: "Organization", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UseRiskInsights", + table: "Organization"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 67390bcbcb..f906543254 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -186,6 +186,9 @@ namespace Bit.SqliteMigrations.Migrations b.Property("UseResetPassword") .HasColumnType("INTEGER"); + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + b.Property("UseScim") .HasColumnType("INTEGER"); From 6a9b7ece2bbfbabbc9f33d7a440f6c892c30011d Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Fri, 6 Dec 2024 08:07:04 +1000 Subject: [PATCH 25/30] [PM-11360] Remove export permission for providers (#5051) - also fix managed collections export from CLI --- .../VaultExportAuthorizationHandler.cs | 38 ++++++++ .../Authorization/VaultExportOperations.cs | 20 ++++ .../OrganizationExportController.cs | 54 ++++++++++- .../OrganizationExportResponseModel.cs | 10 ++ .../Utilities/ServiceCollectionExtensions.cs | 4 +- .../Models/Response/CipherResponseModel.cs | 7 ++ src/Core/Constants.cs | 1 + .../Queries/IOrganizationCiphersQuery.cs | 10 ++ .../Vault/Queries/OrganizationCiphersQuery.cs | 9 ++ .../VaultExportAuthorizationHandlerTests.cs | 95 +++++++++++++++++++ .../Helpers/AuthorizationHelpers.cs | 52 ++++++++++ .../Helpers/AuthorizationHelpersTests.cs | 38 ++++++++ .../Queries/OrganizationCiphersQueryTests.cs | 92 ++++++++++++++++++ 13 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 src/Api/Tools/Authorization/VaultExportAuthorizationHandler.cs create mode 100644 src/Api/Tools/Authorization/VaultExportOperations.cs create mode 100644 test/Api.Test/Tools/Authorization/VaultExportAuthorizationHandlerTests.cs create mode 100644 test/Core.Test/AdminConsole/Helpers/AuthorizationHelpers.cs create mode 100644 test/Core.Test/AdminConsole/Helpers/AuthorizationHelpersTests.cs create mode 100644 test/Core.Test/Vault/Queries/OrganizationCiphersQueryTests.cs diff --git a/src/Api/Tools/Authorization/VaultExportAuthorizationHandler.cs b/src/Api/Tools/Authorization/VaultExportAuthorizationHandler.cs new file mode 100644 index 0000000000..337a0dc1e5 --- /dev/null +++ b/src/Api/Tools/Authorization/VaultExportAuthorizationHandler.cs @@ -0,0 +1,38 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; +using Bit.Core.Context; +using Bit.Core.Enums; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Api.Tools.Authorization; + +public class VaultExportAuthorizationHandler(ICurrentContext currentContext) + : AuthorizationHandler +{ + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, + VaultExportOperationRequirement requirement, OrganizationScope organizationScope) + { + var org = currentContext.GetOrganization(organizationScope); + + var authorized = requirement switch + { + not null when requirement == VaultExportOperations.ExportWholeVault => + CanExportWholeVault(org), + not null when requirement == VaultExportOperations.ExportManagedCollections => + CanExportManagedCollections(org), + _ => false + }; + + if (authorized) + { + context.Succeed(requirement); + } + + return Task.FromResult(0); + } + + private bool CanExportWholeVault(CurrentContextOrganization organization) => organization is + { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or + { Type: OrganizationUserType.Custom, Permissions.AccessImportExport: true }; + + private bool CanExportManagedCollections(CurrentContextOrganization organization) => organization is not null; +} diff --git a/src/Api/Tools/Authorization/VaultExportOperations.cs b/src/Api/Tools/Authorization/VaultExportOperations.cs new file mode 100644 index 0000000000..c88d2c80b1 --- /dev/null +++ b/src/Api/Tools/Authorization/VaultExportOperations.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authorization.Infrastructure; + +namespace Bit.Api.Tools.Authorization; + +public class VaultExportOperationRequirement : OperationAuthorizationRequirement; + +public static class VaultExportOperations +{ + /// + /// Exporting the entire organization vault. + /// + public static readonly VaultExportOperationRequirement ExportWholeVault = + new() { Name = nameof(ExportWholeVault) }; + + /// + /// Exporting only the organization items that the user has Can Manage permissions for + /// + public static readonly VaultExportOperationRequirement ExportManagedCollections = + new() { Name = nameof(ExportManagedCollections) }; +} diff --git a/src/Api/Tools/Controllers/OrganizationExportController.cs b/src/Api/Tools/Controllers/OrganizationExportController.cs index b3c0643b28..144e1be69e 100644 --- a/src/Api/Tools/Controllers/OrganizationExportController.cs +++ b/src/Api/Tools/Controllers/OrganizationExportController.cs @@ -1,11 +1,17 @@ using Bit.Api.Models.Response; +using Bit.Api.Tools.Authorization; using Bit.Api.Tools.Models.Response; using Bit.Api.Vault.Models.Response; +using Bit.Core; +using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.Context; using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Queries; using Bit.Core.Vault.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -21,24 +27,41 @@ public class OrganizationExportController : Controller private readonly ICollectionService _collectionService; private readonly ICipherService _cipherService; private readonly GlobalSettings _globalSettings; + private readonly IFeatureService _featureService; + private readonly IAuthorizationService _authorizationService; + private readonly IOrganizationCiphersQuery _organizationCiphersQuery; + private readonly ICollectionRepository _collectionRepository; public OrganizationExportController( ICurrentContext currentContext, ICipherService cipherService, ICollectionService collectionService, IUserService userService, - GlobalSettings globalSettings) + GlobalSettings globalSettings, + IFeatureService featureService, + IAuthorizationService authorizationService, + IOrganizationCiphersQuery organizationCiphersQuery, + ICollectionRepository collectionRepository) { _currentContext = currentContext; _cipherService = cipherService; _collectionService = collectionService; _userService = userService; _globalSettings = globalSettings; + _featureService = featureService; + _authorizationService = authorizationService; + _organizationCiphersQuery = organizationCiphersQuery; + _collectionRepository = collectionRepository; } [HttpGet("export")] public async Task Export(Guid organizationId) { + if (_featureService.IsEnabled(FeatureFlagKeys.PM11360RemoveProviderExportPermission)) + { + return await Export_vNext(organizationId); + } + var userId = _userService.GetProperUserId(User).Value; IEnumerable orgCollections = await _collectionService.GetOrganizationCollectionsAsync(organizationId); @@ -65,6 +88,35 @@ public class OrganizationExportController : Controller return Ok(organizationExportListResponseModel); } + private async Task Export_vNext(Guid organizationId) + { + 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); + 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)); + + return Ok(new OrganizationExportResponseModel(managedCiphers, managedOrgCollections, _globalSettings)); + } + + // Unauthorized + throw new NotFoundException(); + } + private ListResponseModel GetOrganizationCollectionsResponse(IEnumerable orgCollections) { var collections = orgCollections.Select(c => new CollectionResponseModel(c)); diff --git a/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs index a4b35d8de1..5fd7e821cf 100644 --- a/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs +++ b/src/Api/Tools/Models/Response/OrganizationExportResponseModel.cs @@ -1,6 +1,9 @@ using Bit.Api.Models.Response; using Bit.Api.Vault.Models.Response; +using Bit.Core.Entities; using Bit.Core.Models.Api; +using Bit.Core.Settings; +using Bit.Core.Vault.Models.Data; namespace Bit.Api.Tools.Models.Response; @@ -10,6 +13,13 @@ public class OrganizationExportResponseModel : ResponseModel { } + public OrganizationExportResponseModel(IEnumerable ciphers, + IEnumerable collections, GlobalSettings globalSettings) : this() + { + Ciphers = ciphers.Select(c => new CipherMiniDetailsResponseModel(c, globalSettings)); + Collections = collections.Select(c => new CollectionResponseModel(c)); + } + public IEnumerable Collections { get; set; } public IEnumerable Ciphers { get; set; } diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index 8a58a5f236..3d206fd887 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using Bit.Api.Vault.AuthorizationHandlers.Collections; +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.Settings; @@ -99,5 +100,6 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index 10b77274b5..207017227a 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -166,5 +166,12 @@ public class CipherMiniDetailsResponseModel : CipherMiniResponseModel CollectionIds = cipher.CollectionIds ?? new List(); } + public CipherMiniDetailsResponseModel(CipherOrganizationDetailsWithCollections cipher, + GlobalSettings globalSettings, string obj = "cipherMiniDetails") + : base(cipher, globalSettings, cipher.OrganizationUseTotp, obj) + { + CollectionIds = cipher.CollectionIds ?? new List(); + } + public IEnumerable CollectionIds { get; set; } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 14258353d6..8c1456793d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -153,6 +153,7 @@ public static class FeatureFlagKeys public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; public const string SecurityTasks = "security-tasks"; public const string PM14401_ScaleMSPOnClientOrganizationUpdate = "PM-14401-scale-msp-on-client-organization-update"; + public const string PM11360RemoveProviderExportPermission = "pm-11360-remove-provider-export-permission"; public const string DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship"; public const string MacOsNativeCredentialSync = "macos-native-credential-sync"; public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; diff --git a/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs b/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs index 680743088e..1756cad3c7 100644 --- a/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs +++ b/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs @@ -27,4 +27,14 @@ public interface IOrganizationCiphersQuery ///
/// Task> GetUnassignedOrganizationCiphers(Guid organizationId); + + /// + /// Returns ciphers belonging to the organization that are in the specified collections. + /// + /// + /// Note that the will include all collections + /// the cipher belongs to even if it is not in the parameter. + /// + public Task> GetOrganizationCiphersByCollectionIds( + Guid organizationId, IEnumerable collectionIds); } diff --git a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs index f91e3cbbbb..deed121216 100644 --- a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs +++ b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs @@ -52,4 +52,13 @@ public class OrganizationCiphersQuery : IOrganizationCiphersQuery { return await _cipherRepository.GetManyUnassignedOrganizationDetailsByOrganizationIdAsync(organizationId); } + + /// + public async Task> GetOrganizationCiphersByCollectionIds( + Guid organizationId, IEnumerable collectionIds) + { + var managedCollectionIds = collectionIds.ToHashSet(); + var allOrganizationCiphers = await GetAllOrganizationCiphers(organizationId); + return allOrganizationCiphers.Where(c => c.CollectionIds.Intersect(managedCollectionIds).Any()); + } } diff --git a/test/Api.Test/Tools/Authorization/VaultExportAuthorizationHandlerTests.cs b/test/Api.Test/Tools/Authorization/VaultExportAuthorizationHandlerTests.cs new file mode 100644 index 0000000000..6c42205b1a --- /dev/null +++ b/test/Api.Test/Tools/Authorization/VaultExportAuthorizationHandlerTests.cs @@ -0,0 +1,95 @@ +using System.Security.Claims; +using Bit.Api.Tools.Authorization; +using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Test.AdminConsole.Helpers; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Tools.Authorization; + +[SutProviderCustomize] +public class VaultExportAuthorizationHandlerTests +{ + public static IEnumerable CanExportWholeVault => new List + { + new () { Type = OrganizationUserType.Owner }, + new () { Type = OrganizationUserType.Admin }, + new () + { + Type = OrganizationUserType.Custom, Permissions = new Permissions { AccessImportExport = true } + } + }.Select(org => new[] { org }); + + [Theory] + [BitMemberAutoData(nameof(CanExportWholeVault))] + public async Task ExportAll_PermittedRoles_Success(CurrentContextOrganization org, OrganizationScope orgScope, ClaimsPrincipal user, + SutProvider sutProvider) + { + org.Id = orgScope; + sutProvider.GetDependency().GetOrganization(orgScope).Returns(org); + + var authContext = new AuthorizationHandlerContext(new[] { VaultExportOperations.ExportWholeVault }, user, orgScope); + await sutProvider.Sut.HandleAsync(authContext); + + Assert.True(authContext.HasSucceeded); + } + + public static IEnumerable CannotExportWholeVault => new List + { + new () { Type = OrganizationUserType.User }, + new () + { + Type = OrganizationUserType.Custom, Permissions = new Permissions { AccessImportExport = true }.Invert() + } + }.Select(org => new[] { org }); + + [Theory] + [BitMemberAutoData(nameof(CannotExportWholeVault))] + public async Task ExportAll_NotPermitted_Failure(CurrentContextOrganization org, OrganizationScope orgScope, ClaimsPrincipal user, + SutProvider sutProvider) + { + org.Id = orgScope; + sutProvider.GetDependency().GetOrganization(orgScope).Returns(org); + + var authContext = new AuthorizationHandlerContext(new[] { VaultExportOperations.ExportWholeVault }, user, orgScope); + await sutProvider.Sut.HandleAsync(authContext); + + Assert.False(authContext.HasSucceeded); + } + + public static IEnumerable CanExportManagedCollections => + AuthorizationHelpers.AllRoles().Select(o => new[] { o }); + + [Theory] + [BitMemberAutoData(nameof(CanExportManagedCollections))] + public async Task ExportManagedCollections_PermittedRoles_Success(CurrentContextOrganization org, OrganizationScope orgScope, ClaimsPrincipal user, + SutProvider sutProvider) + { + org.Id = orgScope; + sutProvider.GetDependency().GetOrganization(orgScope).Returns(org); + + var authContext = new AuthorizationHandlerContext(new[] { VaultExportOperations.ExportManagedCollections }, user, orgScope); + await sutProvider.Sut.HandleAsync(authContext); + + Assert.True(authContext.HasSucceeded); + } + + [Theory] + [BitAutoData([null])] + public async Task ExportManagedCollections_NotPermitted_Failure(CurrentContextOrganization org, OrganizationScope orgScope, ClaimsPrincipal user, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetOrganization(orgScope).Returns(org); + + var authContext = new AuthorizationHandlerContext(new[] { VaultExportOperations.ExportManagedCollections }, user, orgScope); + await sutProvider.Sut.HandleAsync(authContext); + + Assert.False(authContext.HasSucceeded); + } +} diff --git a/test/Core.Test/AdminConsole/Helpers/AuthorizationHelpers.cs b/test/Core.Test/AdminConsole/Helpers/AuthorizationHelpers.cs new file mode 100644 index 0000000000..854cdcb3c8 --- /dev/null +++ b/test/Core.Test/AdminConsole/Helpers/AuthorizationHelpers.cs @@ -0,0 +1,52 @@ +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Models.Data; + +namespace Bit.Core.Test.AdminConsole.Helpers; + +public static class AuthorizationHelpers +{ + /// + /// Return a new Permission object with inverted permissions. + /// This is useful to test negative cases, e.g. "all other permissions should fail". + /// + /// + /// + public static Permissions Invert(this Permissions permissions) + { + // Get all false boolean properties of input object + var inputsToFlip = permissions + .GetType() + .GetProperties() + .Where(p => + p.PropertyType == typeof(bool) && + (bool)p.GetValue(permissions, null)! == false) + .Select(p => p.Name); + + var result = new Permissions(); + + // Set these to true on the result object + result + .GetType() + .GetProperties() + .Where(p => inputsToFlip.Contains(p.Name)) + .ToList() + .ForEach(p => p.SetValue(result, true)); + + return result; + } + + /// + /// Returns a sequence of all possible roles and permissions represented as CurrentContextOrganization objects. + /// Used largely for authorization testing. + /// + /// + public static IEnumerable AllRoles() => new List + { + new () { Type = OrganizationUserType.Owner }, + new () { Type = OrganizationUserType.Admin }, + new () { Type = OrganizationUserType.Custom, Permissions = new Permissions() }, + new () { Type = OrganizationUserType.Custom, Permissions = new Permissions().Invert() }, + new () { Type = OrganizationUserType.User }, + }; +} diff --git a/test/Core.Test/AdminConsole/Helpers/AuthorizationHelpersTests.cs b/test/Core.Test/AdminConsole/Helpers/AuthorizationHelpersTests.cs new file mode 100644 index 0000000000..db128ffc4b --- /dev/null +++ b/test/Core.Test/AdminConsole/Helpers/AuthorizationHelpersTests.cs @@ -0,0 +1,38 @@ +using Bit.Core.Models.Data; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Helpers; + +public class AuthorizationHelpersTests +{ + [Fact] + public void Permissions_Invert_InvertsAllPermissions() + { + var sut = new Permissions + { + AccessEventLogs = true, + AccessReports = true, + DeleteAnyCollection = true, + ManagePolicies = true, + ManageScim = true + }; + + var result = sut.Invert(); + + Assert.True(result is + { + AccessEventLogs: false, + AccessImportExport: true, + AccessReports: false, + CreateNewCollections: true, + EditAnyCollection: true, + DeleteAnyCollection: false, + ManageGroups: true, + ManagePolicies: false, + ManageSso: true, + ManageUsers: true, + ManageResetPassword: true, + ManageScim: false + }); + } +} diff --git a/test/Core.Test/Vault/Queries/OrganizationCiphersQueryTests.cs b/test/Core.Test/Vault/Queries/OrganizationCiphersQueryTests.cs new file mode 100644 index 0000000000..01539fe7d7 --- /dev/null +++ b/test/Core.Test/Vault/Queries/OrganizationCiphersQueryTests.cs @@ -0,0 +1,92 @@ +using AutoFixture; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Queries; +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.Queries; + +[SutProviderCustomize] +public class OrganizationCiphersQueryTests +{ + [Theory, BitAutoData] + public async Task GetOrganizationCiphersInCollections_ReturnsFilteredCiphers( + Guid organizationId, SutProvider sutProvider) + { + var fixture = new Fixture(); + + var otherCollectionId = Guid.NewGuid(); + var targetCollectionId = Guid.NewGuid(); + + var otherCipher = fixture.Create(); + var targetCipher = fixture.Create(); + var bothCipher = fixture.Create(); + var noCipher = fixture.Create(); + + var ciphers = new List + { + otherCipher, // not in the target collection + targetCipher, // in the target collection + bothCipher, // in both collections + noCipher // not in any collection + }; + ciphers.ForEach(c => + { + c.OrganizationId = organizationId; + c.UserId = null; + }); + + var otherCollectionCipher = new CollectionCipher + { + CollectionId = otherCollectionId, + CipherId = otherCipher.Id + }; + var targetCollectionCipher = new CollectionCipher + { + CollectionId = targetCollectionId, + CipherId = targetCipher.Id + }; + var bothCollectionCipher1 = new CollectionCipher + { + CollectionId = targetCollectionId, + CipherId = bothCipher.Id + }; + var bothCollectionCipher2 = new CollectionCipher + { + CollectionId = otherCollectionId, + CipherId = bothCipher.Id + }; + + sutProvider.GetDependency().GetManyOrganizationDetailsByOrganizationIdAsync(organizationId) + .Returns(ciphers); + + sutProvider.GetDependency().GetManyByOrganizationIdAsync(organizationId).Returns( + [ + targetCollectionCipher, + otherCollectionCipher, + bothCollectionCipher1, + bothCollectionCipher2 + ]); + + var result = await sutProvider + .Sut + .GetOrganizationCiphersByCollectionIds(organizationId, [targetCollectionId]); + result = result.ToList(); + + Assert.Equal(2, result.Count()); + Assert.Contains(result, c => + c.Id == targetCipher.Id && + c.CollectionIds.Count() == 1 && + c.CollectionIds.Any(cId => cId == targetCollectionId)); + Assert.Contains(result, c => + c.Id == bothCipher.Id && + c.CollectionIds.Count() == 2 && + c.CollectionIds.Any(cId => cId == targetCollectionId) && + c.CollectionIds.Any(cId => cId == otherCollectionId)); + } +} From 2333a934a97320bc189b6e37c47067953f398c40 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 5 Dec 2024 21:18:02 -0600 Subject: [PATCH 26/30] [PM-12488] Migrating Cloud Org Sign Up to Command (#5078) --- .../Controllers/OrganizationsController.cs | 17 +- .../CloudOrganizationSignUpCommand.cs | 368 ++++++++++++++++++ .../Services/IOrganizationService.cs | 6 - .../Implementations/OrganizationService.cs | 124 ------ ...OrganizationServiceCollectionExtensions.cs | 5 + .../Helpers/OrganizationTestHelpers.cs | 10 +- .../OrganizationsControllerTests.cs | 9 +- .../CloudOrganizationSignUpCommandTests.cs | 238 +++++++++++ .../Services/OrganizationServiceTests.cs | 216 ---------- 9 files changed, 630 insertions(+), 363 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 0ac750e665..6ffd60e425 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -13,6 +13,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; @@ -52,11 +53,11 @@ public class OrganizationsController : Controller private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; - private readonly IPushNotificationService _pushNotificationService; private readonly IProviderRepository _providerRepository; private readonly IProviderBillingService _providerBillingService; private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; + private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -73,11 +74,11 @@ public class OrganizationsController : Controller IOrganizationApiKeyRepository organizationApiKeyRepository, IFeatureService featureService, GlobalSettings globalSettings, - IPushNotificationService pushNotificationService, IProviderRepository providerRepository, IProviderBillingService providerBillingService, IDataProtectorTokenFactory orgDeleteTokenDataFactory, - IRemoveOrganizationUserCommand removeOrganizationUserCommand) + IRemoveOrganizationUserCommand removeOrganizationUserCommand, + ICloudOrganizationSignUpCommand cloudOrganizationSignUpCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -93,11 +94,11 @@ public class OrganizationsController : Controller _organizationApiKeyRepository = organizationApiKeyRepository; _featureService = featureService; _globalSettings = globalSettings; - _pushNotificationService = pushNotificationService; _providerRepository = providerRepository; _providerBillingService = providerBillingService; _orgDeleteTokenDataFactory = orgDeleteTokenDataFactory; _removeOrganizationUserCommand = removeOrganizationUserCommand; + _cloudOrganizationSignUpCommand = cloudOrganizationSignUpCommand; } [HttpGet("{id}")] @@ -175,8 +176,8 @@ public class OrganizationsController : Controller } var organizationSignup = model.ToOrganizationSignup(user); - var result = await _organizationService.SignUpAsync(organizationSignup); - return new OrganizationResponseModel(result.Item1); + var result = await _cloudOrganizationSignUpCommand.SignUpOrganizationAsync(organizationSignup); + return new OrganizationResponseModel(result.Organization); } [HttpPost("create-without-payment")] @@ -190,8 +191,8 @@ public class OrganizationsController : Controller } var organizationSignup = model.ToOrganizationSignup(user); - var result = await _organizationService.SignUpAsync(organizationSignup); - return new OrganizationResponseModel(result.Item1); + var result = await _cloudOrganizationSignUpCommand.SignUpOrganizationAsync(organizationSignup); + return new OrganizationResponseModel(result.Organization); } [HttpPut("{id}")] diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs new file mode 100644 index 0000000000..3eb4d35ef1 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -0,0 +1,368 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Services; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.Data; +using Bit.Core.Models.StaticStore; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public record SignUpOrganizationResponse( + Organization Organization, + OrganizationUser OrganizationUser, + Collection DefaultCollection); + +public interface ICloudOrganizationSignUpCommand +{ + Task SignUpOrganizationAsync(OrganizationSignup signup); +} + +public class CloudOrganizationSignUpCommand( + IOrganizationUserRepository organizationUserRepository, + IFeatureService featureService, + IOrganizationBillingService organizationBillingService, + IPaymentService paymentService, + IPolicyService policyService, + IReferenceEventService referenceEventService, + ICurrentContext currentContext, + IOrganizationRepository organizationRepository, + IOrganizationApiKeyRepository organizationApiKeyRepository, + IApplicationCacheService applicationCacheService, + IPushRegistrationService pushRegistrationService, + IPushNotificationService pushNotificationService, + ICollectionRepository collectionRepository, + IDeviceRepository deviceRepository) : ICloudOrganizationSignUpCommand +{ + public async Task SignUpOrganizationAsync(OrganizationSignup signup) + { + var plan = StaticStore.GetPlan(signup.Plan); + + ValidatePasswordManagerPlan(plan, signup); + + if (signup.UseSecretsManager) + { + if (signup.IsFromProvider) + { + throw new BadRequestException( + "Organizations with a Managed Service Provider do not support Secrets Manager."); + } + ValidateSecretsManagerPlan(plan, signup); + } + + if (!signup.IsFromProvider) + { + await ValidateSignUpPoliciesAsync(signup.Owner.Id); + } + + var organization = new Organization + { + // Pre-generate the org id so that we can save it with the Stripe subscription + Id = CoreHelpers.GenerateComb(), + Name = signup.Name, + BillingEmail = signup.BillingEmail, + BusinessName = signup.BusinessName, + PlanType = plan!.Type, + Seats = (short)(plan.PasswordManager.BaseSeats + signup.AdditionalSeats), + MaxCollections = plan.PasswordManager.MaxCollections, + MaxStorageGb = !plan.PasswordManager.BaseStorageGb.HasValue ? + (short?)null : (short)(plan.PasswordManager.BaseStorageGb.Value + signup.AdditionalStorageGb), + UsePolicies = plan.HasPolicies, + UseSso = plan.HasSso, + UseGroups = plan.HasGroups, + UseEvents = plan.HasEvents, + UseDirectory = plan.HasDirectory, + UseTotp = plan.HasTotp, + Use2fa = plan.Has2fa, + UseApi = plan.HasApi, + UseResetPassword = plan.HasResetPassword, + SelfHost = plan.HasSelfHost, + UsersGetPremium = plan.UsersGetPremium || signup.PremiumAccessAddon, + UseCustomPermissions = plan.HasCustomPermissions, + UseScim = plan.HasScim, + Plan = plan.Name, + Gateway = null, + ReferenceData = signup.Owner.ReferenceData, + Enabled = true, + LicenseKey = CoreHelpers.SecureRandomString(20), + PublicKey = signup.PublicKey, + PrivateKey = signup.PrivateKey, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + Status = OrganizationStatusType.Created, + UsePasswordManager = true, + UseSecretsManager = signup.UseSecretsManager + }; + + if (signup.UseSecretsManager) + { + organization.SmSeats = plan.SecretsManager.BaseSeats + signup.AdditionalSmSeats.GetValueOrDefault(); + organization.SmServiceAccounts = plan.SecretsManager.BaseServiceAccount + + signup.AdditionalServiceAccounts.GetValueOrDefault(); + } + + if (plan.Type == PlanType.Free && !signup.IsFromProvider) + { + var adminCount = + await organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id); + if (adminCount > 0) + { + throw new BadRequestException("You can only be an admin of one free organization."); + } + } + else if (plan.Type != PlanType.Free) + { + if (featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI)) + { + var sale = OrganizationSale.From(organization, signup); + await organizationBillingService.Finalize(sale); + } + else + { + if (signup.PaymentMethodType != null) + { + await paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value, + signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, + signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(), + signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); + } + else + { + await paymentService.PurchaseOrganizationNoPaymentMethod(organization, plan, signup.AdditionalSeats, + signup.PremiumAccessAddon, signup.AdditionalSmSeats.GetValueOrDefault(), + signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); + } + + } + } + + var ownerId = signup.IsFromProvider ? default : signup.Owner.Id; + var returnValue = await SignUpAsync(organization, ownerId, signup.OwnerKey, signup.CollectionName, true); + await referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.Signup, organization, currentContext) + { + PlanName = plan.Name, + PlanType = plan.Type, + Seats = returnValue.Item1.Seats, + SignupInitiationPath = signup.InitiationPath, + Storage = returnValue.Item1.MaxStorageGb, + // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 + }); + + return new SignUpOrganizationResponse(returnValue.organization, returnValue.organizationUser, returnValue.defaultCollection); + } + + public void ValidatePasswordManagerPlan(Plan plan, OrganizationUpgrade upgrade) + { + ValidatePlan(plan, upgrade.AdditionalSeats, "Password Manager"); + + if (plan.PasswordManager.BaseSeats + upgrade.AdditionalSeats <= 0) + { + throw new BadRequestException($"You do not have any Password Manager seats!"); + } + + if (upgrade.AdditionalSeats < 0) + { + throw new BadRequestException($"You can't subtract Password Manager seats!"); + } + + if (!plan.PasswordManager.HasAdditionalStorageOption && upgrade.AdditionalStorageGb > 0) + { + throw new BadRequestException("Plan does not allow additional storage."); + } + + if (upgrade.AdditionalStorageGb < 0) + { + throw new BadRequestException("You can't subtract storage!"); + } + + if (!plan.PasswordManager.HasPremiumAccessOption && upgrade.PremiumAccessAddon) + { + throw new BadRequestException("This plan does not allow you to buy the premium access addon."); + } + + if (!plan.PasswordManager.HasAdditionalSeatsOption && upgrade.AdditionalSeats > 0) + { + throw new BadRequestException("Plan does not allow additional users."); + } + + if (plan.PasswordManager.HasAdditionalSeatsOption && plan.PasswordManager.MaxAdditionalSeats.HasValue && + upgrade.AdditionalSeats > plan.PasswordManager.MaxAdditionalSeats.Value) + { + throw new BadRequestException($"Selected plan allows a maximum of " + + $"{plan.PasswordManager.MaxAdditionalSeats.GetValueOrDefault(0)} additional users."); + } + } + + public void ValidateSecretsManagerPlan(Plan plan, OrganizationUpgrade upgrade) + { + if (plan.SupportsSecretsManager == false) + { + throw new BadRequestException("Invalid Secrets Manager plan selected."); + } + + ValidatePlan(plan, upgrade.AdditionalSmSeats.GetValueOrDefault(), "Secrets Manager"); + + if (plan.SecretsManager.BaseSeats + upgrade.AdditionalSmSeats <= 0) + { + throw new BadRequestException($"You do not have any Secrets Manager seats!"); + } + + if (!plan.SecretsManager.HasAdditionalServiceAccountOption && upgrade.AdditionalServiceAccounts > 0) + { + throw new BadRequestException("Plan does not allow additional Machine Accounts."); + } + + if ((plan.ProductTier == ProductTierType.TeamsStarter && + upgrade.AdditionalSmSeats.GetValueOrDefault() > plan.PasswordManager.BaseSeats) || + (plan.ProductTier != ProductTierType.TeamsStarter && + upgrade.AdditionalSmSeats.GetValueOrDefault() > upgrade.AdditionalSeats)) + { + throw new BadRequestException("You cannot have more Secrets Manager seats than Password Manager seats."); + } + + if (upgrade.AdditionalServiceAccounts.GetValueOrDefault() < 0) + { + throw new BadRequestException("You can't subtract Machine Accounts!"); + } + + switch (plan.SecretsManager.HasAdditionalSeatsOption) + { + case false when upgrade.AdditionalSmSeats > 0: + throw new BadRequestException("Plan does not allow additional users."); + case true when plan.SecretsManager.MaxAdditionalSeats.HasValue && + upgrade.AdditionalSmSeats > plan.SecretsManager.MaxAdditionalSeats.Value: + throw new BadRequestException($"Selected plan allows a maximum of " + + $"{plan.SecretsManager.MaxAdditionalSeats.GetValueOrDefault(0)} additional users."); + } + } + + private static void ValidatePlan(Plan plan, int additionalSeats, string productType) + { + if (plan is null) + { + throw new BadRequestException($"{productType} Plan was null."); + } + + if (plan.Disabled) + { + throw new BadRequestException($"{productType} Plan not found."); + } + + if (additionalSeats < 0) + { + throw new BadRequestException($"You can't subtract {productType} seats!"); + } + } + + private async Task ValidateSignUpPoliciesAsync(Guid ownerId) + { + var anySingleOrgPolicies = await policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); + if (anySingleOrgPolicies) + { + throw new BadRequestException("You may not create an organization. You belong to an organization " + + "which has a policy that prohibits you from being a member of any other organization."); + } + } + + private async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignUpAsync(Organization organization, + Guid ownerId, string ownerKey, string collectionName, bool withPayment) + { + try + { + await organizationRepository.CreateAsync(organization); + await organizationApiKeyRepository.CreateAsync(new OrganizationApiKey + { + OrganizationId = organization.Id, + ApiKey = CoreHelpers.SecureRandomString(30), + Type = OrganizationApiKeyType.Default, + RevisionDate = DateTime.UtcNow, + }); + await applicationCacheService.UpsertOrganizationAbilityAsync(organization); + + // ownerId == default if the org is created by a provider - in this case it's created without an + // owner and the first owner is immediately invited afterwards + OrganizationUser orgUser = null; + if (ownerId != default) + { + orgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = ownerId, + Key = ownerKey, + AccessSecretsManager = organization.UseSecretsManager, + Type = OrganizationUserType.Owner, + Status = OrganizationUserStatusType.Confirmed, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + orgUser.SetNewId(); + + await organizationUserRepository.CreateAsync(orgUser); + + var devices = await GetUserDeviceIdsAsync(orgUser.UserId.Value); + await pushRegistrationService.AddUserRegistrationOrganizationAsync(devices, organization.Id.ToString()); + await pushNotificationService.PushSyncOrgKeysAsync(ownerId); + } + + Collection defaultCollection = null; + if (!string.IsNullOrWhiteSpace(collectionName)) + { + defaultCollection = new Collection + { + Name = collectionName, + OrganizationId = organization.Id, + CreationDate = organization.CreationDate, + RevisionDate = organization.CreationDate + }; + + // Give the owner Can Manage access over the default collection + List defaultOwnerAccess = null; + if (orgUser != null) + { + defaultOwnerAccess = + [new CollectionAccessSelection { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }]; + } + + await collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess); + } + + return (organization, orgUser, defaultCollection); + } + catch + { + if (withPayment) + { + await paymentService.CancelAndRecoverChargesAsync(organization); + } + + if (organization.Id != default(Guid)) + { + await organizationRepository.DeleteAsync(organization); + await applicationCacheService.DeleteOrganizationAbilityAsync(organization.Id); + } + + throw; + } + } + + private async Task> GetUserDeviceIdsAsync(Guid userId) + { + var devices = await deviceRepository.GetManyByUserIdAsync(userId); + return devices + .Where(d => !string.IsNullOrWhiteSpace(d.PushToken)) + .Select(d => d.Id.ToString()); + } +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 646ae66166..0495c4c76e 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -20,13 +20,7 @@ public interface IOrganizationService Task AutoAddSeatsAsync(Organization organization, int seatsToAdd); Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment); Task VerifyBankAsync(Guid organizationId, int amount1, int amount2); - /// - /// Create a new organization in a cloud environment - /// - /// A tuple containing the new organization, the initial organizationUser (if any) and the default collection (if any) #nullable enable - Task<(Organization organization, OrganizationUser? organizationUser, Collection? defaultCollection)> SignUpAsync(OrganizationSignup organizationSignup); - Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup); #nullable disable /// diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 862b566c91..1ca047aa47 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -16,7 +16,6 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; @@ -502,129 +501,6 @@ public class OrganizationService : IOrganizationService return returnValue; } - /// - /// Create a new organization in a cloud environment - /// - public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignUpAsync(OrganizationSignup signup) - { - var plan = StaticStore.GetPlan(signup.Plan); - - ValidatePasswordManagerPlan(plan, signup); - - if (signup.UseSecretsManager) - { - if (signup.IsFromProvider) - { - throw new BadRequestException( - "Organizations with a Managed Service Provider do not support Secrets Manager."); - } - ValidateSecretsManagerPlan(plan, signup); - } - - if (!signup.IsFromProvider) - { - await ValidateSignUpPoliciesAsync(signup.Owner.Id); - } - - var organization = new Organization - { - // Pre-generate the org id so that we can save it with the Stripe subscription.. - Id = CoreHelpers.GenerateComb(), - Name = signup.Name, - BillingEmail = signup.BillingEmail, - BusinessName = signup.BusinessName, - PlanType = plan!.Type, - Seats = (short)(plan.PasswordManager.BaseSeats + signup.AdditionalSeats), - MaxCollections = plan.PasswordManager.MaxCollections, - MaxStorageGb = !plan.PasswordManager.BaseStorageGb.HasValue ? - (short?)null : (short)(plan.PasswordManager.BaseStorageGb.Value + signup.AdditionalStorageGb), - UsePolicies = plan.HasPolicies, - UseSso = plan.HasSso, - UseGroups = plan.HasGroups, - UseEvents = plan.HasEvents, - UseDirectory = plan.HasDirectory, - UseTotp = plan.HasTotp, - Use2fa = plan.Has2fa, - UseApi = plan.HasApi, - UseResetPassword = plan.HasResetPassword, - SelfHost = plan.HasSelfHost, - UsersGetPremium = plan.UsersGetPremium || signup.PremiumAccessAddon, - UseCustomPermissions = plan.HasCustomPermissions, - UseScim = plan.HasScim, - Plan = plan.Name, - Gateway = null, - ReferenceData = signup.Owner.ReferenceData, - Enabled = true, - LicenseKey = CoreHelpers.SecureRandomString(20), - PublicKey = signup.PublicKey, - PrivateKey = signup.PrivateKey, - CreationDate = DateTime.UtcNow, - RevisionDate = DateTime.UtcNow, - Status = OrganizationStatusType.Created, - UsePasswordManager = true, - UseSecretsManager = signup.UseSecretsManager - }; - - if (signup.UseSecretsManager) - { - organization.SmSeats = plan.SecretsManager.BaseSeats + signup.AdditionalSmSeats.GetValueOrDefault(); - organization.SmServiceAccounts = plan.SecretsManager.BaseServiceAccount + - signup.AdditionalServiceAccounts.GetValueOrDefault(); - } - - if (plan.Type == PlanType.Free && !signup.IsFromProvider) - { - var adminCount = - await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id); - if (adminCount > 0) - { - throw new BadRequestException("You can only be an admin of one free organization."); - } - } - else if (plan.Type != PlanType.Free) - { - var deprecateStripeSourcesAPI = _featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI); - - if (deprecateStripeSourcesAPI) - { - var sale = OrganizationSale.From(organization, signup); - await _organizationBillingService.Finalize(sale); - } - else - { - if (signup.PaymentMethodType != null) - { - await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value, - signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, - signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(), - signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); - } - else - { - await _paymentService.PurchaseOrganizationNoPaymentMethod(organization, plan, signup.AdditionalSeats, - signup.PremiumAccessAddon, signup.AdditionalSmSeats.GetValueOrDefault(), - signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); - } - - } - } - - var ownerId = signup.IsFromProvider ? default : signup.Owner.Id; - var returnValue = await SignUpAsync(organization, ownerId, signup.OwnerKey, signup.CollectionName, true); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext) - { - PlanName = plan.Name, - PlanType = plan.Type, - Seats = returnValue.Item1.Seats, - SignupInitiationPath = signup.InitiationPath, - Storage = returnValue.Item1.MaxStorageGb, - // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 - }); - - return returnValue; - } - private async Task ValidateSignUpPoliciesAsync(Guid ownerId) { var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 96fdefcfad..5586273520 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; @@ -50,12 +51,16 @@ public static class OrganizationServiceCollectionExtensions services.AddOrganizationGroupCommands(); services.AddOrganizationLicenseCommandsQueries(); services.AddOrganizationDomainCommandsQueries(); + services.AddOrganizationSignUpCommands(); services.AddOrganizationAuthCommands(); services.AddOrganizationUserCommands(); services.AddOrganizationUserCommandsQueries(); services.AddBaseOrganizationSubscriptionCommandsQueries(); } + private static IServiceCollection AddOrganizationSignUpCommands(this IServiceCollection services) => + services.AddScoped(); + private static void AddOrganizationConnectionCommands(this IServiceCollection services) { services.AddScoped(); diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index 64f719e82e..dd514803fe 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -1,13 +1,13 @@ using System.Diagnostics; using Bit.Api.IntegrationTest.Factories; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.IntegrationTestCommon.Factories; namespace Bit.Api.IntegrationTest.Helpers; @@ -24,11 +24,11 @@ public static class OrganizationTestHelpers PaymentMethodType paymentMethod = PaymentMethodType.None) where T : class { var userRepository = factory.GetService(); - var organizationService = factory.GetService(); + var organizationSignUpCommand = factory.GetService(); var owner = await userRepository.GetByEmailAsync(ownerEmail); - var signUpResult = await organizationService.SignUpAsync(new OrganizationSignup + var signUpResult = await organizationSignUpCommand.SignUpOrganizationAsync(new OrganizationSignup { Name = name, BillingEmail = billingEmail, @@ -39,9 +39,9 @@ public static class OrganizationTestHelpers PaymentMethodType = paymentMethod }); - Debug.Assert(signUpResult.organizationUser is not null); + Debug.Assert(signUpResult.OrganizationUser is not null); - return new Tuple(signUpResult.organization, signUpResult.organizationUser); + return new Tuple(signUpResult.Organization, signUpResult.OrganizationUser); } /// diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 27c0f7a7c3..e58cd05b9d 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; @@ -46,11 +47,11 @@ public class OrganizationsControllerTests : IDisposable private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository; private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand; private readonly IFeatureService _featureService; - private readonly IPushNotificationService _pushNotificationService; private readonly IProviderRepository _providerRepository; private readonly IProviderBillingService _providerBillingService; private readonly IDataProtectorTokenFactory _orgDeleteTokenDataFactory; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; + private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand; private readonly OrganizationsController _sut; public OrganizationsControllerTests() @@ -69,11 +70,11 @@ public class OrganizationsControllerTests : IDisposable _userService = Substitute.For(); _createOrganizationApiKeyCommand = Substitute.For(); _featureService = Substitute.For(); - _pushNotificationService = Substitute.For(); _providerRepository = Substitute.For(); _providerBillingService = Substitute.For(); _orgDeleteTokenDataFactory = Substitute.For>(); _removeOrganizationUserCommand = Substitute.For(); + _cloudOrganizationSignUpCommand = Substitute.For(); _sut = new OrganizationsController( _organizationRepository, @@ -90,11 +91,11 @@ public class OrganizationsControllerTests : IDisposable _organizationApiKeyRepository, _featureService, _globalSettings, - _pushNotificationService, _providerRepository, _providerBillingService, _orgDeleteTokenDataFactory, - _removeOrganizationUserCommand); + _removeOrganizationUserCommand, + _cloudOrganizationSignUpCommand); } public void Dispose() diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs new file mode 100644 index 0000000000..2c32f0504b --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs @@ -0,0 +1,238 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.Data; +using Bit.Core.Models.StaticStore; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations.OrganizationSignUp; + +[SutProviderCustomize] +public class CloudICloudOrganizationSignUpCommandTests +{ + [Theory] + [BitAutoData(PlanType.FamiliesAnnually)] + public async Task SignUp_PM_Family_Passes(PlanType planType, OrganizationSignup signup, SutProvider sutProvider) + { + signup.Plan = planType; + + var plan = StaticStore.GetPlan(signup.Plan); + + signup.AdditionalSeats = 0; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.UseSecretsManager = false; + signup.IsFromSecretsManagerTrial = false; + + var result = await sutProvider.Sut.SignUpOrganizationAsync(signup); + + await sutProvider.GetDependency().Received(1).CreateAsync( + Arg.Is(o => + o.Seats == plan.PasswordManager.BaseSeats + signup.AdditionalSeats + && o.SmSeats == null + && o.SmServiceAccounts == null)); + await sutProvider.GetDependency().Received(1).CreateAsync( + Arg.Is(o => o.AccessSecretsManager == signup.UseSecretsManager)); + + await sutProvider.GetDependency().Received(1) + .RaiseEventAsync(Arg.Is(referenceEvent => + referenceEvent.Type == ReferenceEventType.Signup && + referenceEvent.PlanName == plan.Name && + referenceEvent.PlanType == plan.Type && + referenceEvent.Seats == result.Organization.Seats && + referenceEvent.Storage == result.Organization.MaxStorageGb)); + // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 + + Assert.NotNull(result.Organization); + Assert.NotNull(result.OrganizationUser); + + await sutProvider.GetDependency().Received(1).PurchaseOrganizationAsync( + Arg.Any(), + signup.PaymentMethodType.Value, + signup.PaymentToken, + plan, + signup.AdditionalStorageGb, + signup.AdditionalSeats, + signup.PremiumAccessAddon, + signup.TaxInfo, + false, + signup.AdditionalSmSeats.GetValueOrDefault(), + signup.AdditionalServiceAccounts.GetValueOrDefault(), + signup.UseSecretsManager + ); + } + + [Theory] + [BitAutoData(PlanType.FamiliesAnnually)] + public async Task SignUp_AssignsOwnerToDefaultCollection + (PlanType planType, OrganizationSignup signup, SutProvider sutProvider) + { + signup.Plan = planType; + signup.AdditionalSeats = 0; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.UseSecretsManager = false; + + // Extract orgUserId when created + Guid? orgUserId = null; + await sutProvider.GetDependency() + .CreateAsync(Arg.Do(ou => orgUserId = ou.Id)); + + var result = await sutProvider.Sut.SignUpOrganizationAsync(signup); + + // Assert: created a Can Manage association for the default collection + Assert.NotNull(orgUserId); + await sutProvider.GetDependency().Received(1).CreateAsync( + Arg.Any(), + Arg.Is>(cas => cas == null), + Arg.Is>(cas => + cas.Count() == 1 && + cas.All(c => + c.Id == orgUserId && + !c.ReadOnly && + !c.HidePasswords && + c.Manage))); + + Assert.NotNull(result.Organization); + Assert.NotNull(result.OrganizationUser); + } + + [Theory] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.TeamsMonthly)] + public async Task SignUp_SM_Passes(PlanType planType, OrganizationSignup signup, SutProvider sutProvider) + { + signup.Plan = planType; + + var plan = StaticStore.GetPlan(signup.Plan); + + signup.UseSecretsManager = true; + signup.AdditionalSeats = 15; + signup.AdditionalSmSeats = 10; + signup.AdditionalServiceAccounts = 20; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.IsFromSecretsManagerTrial = false; + + var result = await sutProvider.Sut.SignUpOrganizationAsync(signup); + + await sutProvider.GetDependency().Received(1).CreateAsync( + Arg.Is(o => + o.Seats == plan.PasswordManager.BaseSeats + signup.AdditionalSeats + && o.SmSeats == plan.SecretsManager.BaseSeats + signup.AdditionalSmSeats + && o.SmServiceAccounts == plan.SecretsManager.BaseServiceAccount + signup.AdditionalServiceAccounts)); + await sutProvider.GetDependency().Received(1).CreateAsync( + Arg.Is(o => o.AccessSecretsManager == signup.UseSecretsManager)); + + await sutProvider.GetDependency().Received(1) + .RaiseEventAsync(Arg.Is(referenceEvent => + referenceEvent.Type == ReferenceEventType.Signup && + referenceEvent.PlanName == plan.Name && + referenceEvent.PlanType == plan.Type && + referenceEvent.Seats == result.Organization.Seats && + referenceEvent.Storage == result.Organization.MaxStorageGb)); + // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 + + Assert.NotNull(result.Organization); + Assert.NotNull(result.OrganizationUser); + + await sutProvider.GetDependency().Received(1).PurchaseOrganizationAsync( + Arg.Any(), + signup.PaymentMethodType.Value, + signup.PaymentToken, + Arg.Is(plan), + signup.AdditionalStorageGb, + signup.AdditionalSeats, + signup.PremiumAccessAddon, + signup.TaxInfo, + false, + signup.AdditionalSmSeats.GetValueOrDefault(), + signup.AdditionalServiceAccounts.GetValueOrDefault(), + signup.IsFromSecretsManagerTrial + ); + } + + [Theory] + [BitAutoData(PlanType.EnterpriseAnnually)] + public async Task SignUp_SM_Throws_WhenManagedByMSP(PlanType planType, OrganizationSignup signup, SutProvider sutProvider) + { + signup.Plan = planType; + signup.UseSecretsManager = true; + signup.AdditionalSeats = 15; + signup.AdditionalSmSeats = 10; + signup.AdditionalServiceAccounts = 20; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.IsFromProvider = true; + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SignUpOrganizationAsync(signup)); + Assert.Contains("Organizations with a Managed Service Provider do not support Secrets Manager.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SignUpAsync_SecretManager_AdditionalServiceAccounts_NotAllowedByPlan_ShouldThrowException(OrganizationSignup signup, SutProvider sutProvider) + { + signup.AdditionalSmSeats = 0; + signup.AdditionalSeats = 0; + signup.Plan = PlanType.Free; + signup.UseSecretsManager = true; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.AdditionalServiceAccounts = 10; + signup.AdditionalStorageGb = 0; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpOrganizationAsync(signup)); + Assert.Contains("Plan does not allow additional Machine Accounts.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SignUpAsync_SMSeatsGreatThanPMSeat_ShouldThrowException(OrganizationSignup signup, SutProvider sutProvider) + { + signup.AdditionalSmSeats = 100; + signup.AdditionalSeats = 10; + signup.Plan = PlanType.EnterpriseAnnually; + signup.UseSecretsManager = true; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.AdditionalServiceAccounts = 10; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpOrganizationAsync(signup)); + Assert.Contains("You cannot have more Secrets Manager seats than Password Manager seats", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SignUpAsync_InvalidateServiceAccount_ShouldThrowException(OrganizationSignup signup, SutProvider sutProvider) + { + signup.AdditionalSmSeats = 10; + signup.AdditionalSeats = 10; + signup.Plan = PlanType.EnterpriseAnnually; + signup.UseSecretsManager = true; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.AdditionalServiceAccounts = -10; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpOrganizationAsync(signup)); + Assert.Contains("You can't subtract Machine Accounts!", exception.Message); + } +} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index f0b7084fe9..fc839030aa 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -20,7 +20,6 @@ using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Mail; -using Bit.Core.Models.StaticStore; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; @@ -200,221 +199,6 @@ public class OrganizationServiceTests referenceEvent.Users == expectedNewUsersCount)); } - [Theory] - [BitAutoData(PlanType.FamiliesAnnually)] - public async Task SignUp_PM_Family_Passes(PlanType planType, OrganizationSignup signup, SutProvider sutProvider) - { - signup.Plan = planType; - - var plan = StaticStore.GetPlan(signup.Plan); - - signup.AdditionalSeats = 0; - signup.PaymentMethodType = PaymentMethodType.Card; - signup.PremiumAccessAddon = false; - signup.UseSecretsManager = false; - signup.IsFromSecretsManagerTrial = false; - - var purchaseOrganizationPlan = StaticStore.GetPlan(signup.Plan); - - var result = await sutProvider.Sut.SignUpAsync(signup); - - await sutProvider.GetDependency().Received(1).CreateAsync( - Arg.Is(o => - o.Seats == plan.PasswordManager.BaseSeats + signup.AdditionalSeats - && o.SmSeats == null - && o.SmServiceAccounts == null)); - await sutProvider.GetDependency().Received(1).CreateAsync( - Arg.Is(o => o.AccessSecretsManager == signup.UseSecretsManager)); - - await sutProvider.GetDependency().Received(1) - .RaiseEventAsync(Arg.Is(referenceEvent => - referenceEvent.Type == ReferenceEventType.Signup && - referenceEvent.PlanName == plan.Name && - referenceEvent.PlanType == plan.Type && - referenceEvent.Seats == result.Item1.Seats && - referenceEvent.Storage == result.Item1.MaxStorageGb)); - // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 - - Assert.NotNull(result.Item1); - Assert.NotNull(result.Item2); - - await sutProvider.GetDependency().Received(1).PurchaseOrganizationAsync( - Arg.Any(), - signup.PaymentMethodType.Value, - signup.PaymentToken, - plan, - signup.AdditionalStorageGb, - signup.AdditionalSeats, - signup.PremiumAccessAddon, - signup.TaxInfo, - false, - signup.AdditionalSmSeats.GetValueOrDefault(), - signup.AdditionalServiceAccounts.GetValueOrDefault(), - signup.UseSecretsManager - ); - } - - [Theory] - [BitAutoData(PlanType.FamiliesAnnually)] - public async Task SignUp_AssignsOwnerToDefaultCollection - (PlanType planType, OrganizationSignup signup, SutProvider sutProvider) - { - signup.Plan = planType; - signup.AdditionalSeats = 0; - signup.PaymentMethodType = PaymentMethodType.Card; - signup.PremiumAccessAddon = false; - signup.UseSecretsManager = false; - - // Extract orgUserId when created - Guid? orgUserId = null; - await sutProvider.GetDependency() - .CreateAsync(Arg.Do(ou => orgUserId = ou.Id)); - - var result = await sutProvider.Sut.SignUpAsync(signup); - - // Assert: created a Can Manage association for the default collection - Assert.NotNull(orgUserId); - await sutProvider.GetDependency().Received(1).CreateAsync( - Arg.Any(), - Arg.Is>(cas => cas == null), - Arg.Is>(cas => - cas.Count() == 1 && - cas.All(c => - c.Id == orgUserId && - !c.ReadOnly && - !c.HidePasswords && - c.Manage))); - - Assert.NotNull(result.Item1); - Assert.NotNull(result.Item2); - } - - [Theory] - [BitAutoData(PlanType.EnterpriseAnnually)] - [BitAutoData(PlanType.EnterpriseMonthly)] - [BitAutoData(PlanType.TeamsAnnually)] - [BitAutoData(PlanType.TeamsMonthly)] - public async Task SignUp_SM_Passes(PlanType planType, OrganizationSignup signup, SutProvider sutProvider) - { - signup.Plan = planType; - - var plan = StaticStore.GetPlan(signup.Plan); - - signup.UseSecretsManager = true; - signup.AdditionalSeats = 15; - signup.AdditionalSmSeats = 10; - signup.AdditionalServiceAccounts = 20; - signup.PaymentMethodType = PaymentMethodType.Card; - signup.PremiumAccessAddon = false; - signup.IsFromSecretsManagerTrial = false; - - var result = await sutProvider.Sut.SignUpAsync(signup); - - await sutProvider.GetDependency().Received(1).CreateAsync( - Arg.Is(o => - o.Seats == plan.PasswordManager.BaseSeats + signup.AdditionalSeats - && o.SmSeats == plan.SecretsManager.BaseSeats + signup.AdditionalSmSeats - && o.SmServiceAccounts == plan.SecretsManager.BaseServiceAccount + signup.AdditionalServiceAccounts)); - await sutProvider.GetDependency().Received(1).CreateAsync( - Arg.Is(o => o.AccessSecretsManager == signup.UseSecretsManager)); - - await sutProvider.GetDependency().Received(1) - .RaiseEventAsync(Arg.Is(referenceEvent => - referenceEvent.Type == ReferenceEventType.Signup && - referenceEvent.PlanName == plan.Name && - referenceEvent.PlanType == plan.Type && - referenceEvent.Seats == result.Item1.Seats && - referenceEvent.Storage == result.Item1.MaxStorageGb)); - // TODO: add reference events for SmSeats and Service Accounts - see AC-1481 - - Assert.NotNull(result.Item1); - Assert.NotNull(result.Item2); - - await sutProvider.GetDependency().Received(1).PurchaseOrganizationAsync( - Arg.Any(), - signup.PaymentMethodType.Value, - signup.PaymentToken, - Arg.Is(plan), - signup.AdditionalStorageGb, - signup.AdditionalSeats, - signup.PremiumAccessAddon, - signup.TaxInfo, - false, - signup.AdditionalSmSeats.GetValueOrDefault(), - signup.AdditionalServiceAccounts.GetValueOrDefault(), - signup.IsFromSecretsManagerTrial - ); - } - - [Theory] - [BitAutoData(PlanType.EnterpriseAnnually)] - public async Task SignUp_SM_Throws_WhenManagedByMSP(PlanType planType, OrganizationSignup signup, SutProvider sutProvider) - { - signup.Plan = planType; - signup.UseSecretsManager = true; - signup.AdditionalSeats = 15; - signup.AdditionalSmSeats = 10; - signup.AdditionalServiceAccounts = 20; - signup.PaymentMethodType = PaymentMethodType.Card; - signup.PremiumAccessAddon = false; - signup.IsFromProvider = true; - - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SignUpAsync(signup)); - Assert.Contains("Organizations with a Managed Service Provider do not support Secrets Manager.", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task SignUpAsync_SecretManager_AdditionalServiceAccounts_NotAllowedByPlan_ShouldThrowException(OrganizationSignup signup, SutProvider sutProvider) - { - signup.AdditionalSmSeats = 0; - signup.AdditionalSeats = 0; - signup.Plan = PlanType.Free; - signup.UseSecretsManager = true; - signup.PaymentMethodType = PaymentMethodType.Card; - signup.PremiumAccessAddon = false; - signup.AdditionalServiceAccounts = 10; - signup.AdditionalStorageGb = 0; - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.SignUpAsync(signup)); - Assert.Contains("Plan does not allow additional Machine Accounts.", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task SignUpAsync_SMSeatsGreatThanPMSeat_ShouldThrowException(OrganizationSignup signup, SutProvider sutProvider) - { - signup.AdditionalSmSeats = 100; - signup.AdditionalSeats = 10; - signup.Plan = PlanType.EnterpriseAnnually; - signup.UseSecretsManager = true; - signup.PaymentMethodType = PaymentMethodType.Card; - signup.PremiumAccessAddon = false; - signup.AdditionalServiceAccounts = 10; - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.SignUpAsync(signup)); - Assert.Contains("You cannot have more Secrets Manager seats than Password Manager seats", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task SignUpAsync_InvalidateServiceAccount_ShouldThrowException(OrganizationSignup signup, SutProvider sutProvider) - { - signup.AdditionalSmSeats = 10; - signup.AdditionalSeats = 10; - signup.Plan = PlanType.EnterpriseAnnually; - signup.UseSecretsManager = true; - signup.PaymentMethodType = PaymentMethodType.Card; - signup.PremiumAccessAddon = false; - signup.AdditionalServiceAccounts = -10; - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.SignUpAsync(signup)); - Assert.Contains("You can't subtract Machine Accounts!", exception.Message); - } - [Theory, BitAutoData] public async Task SignupClientAsync_Succeeds( OrganizationSignup signup, From 092b0b8bd2ad8a335e5f5af52bab4046017517cc Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Fri, 6 Dec 2024 05:46:17 -0500 Subject: [PATCH 27/30] Remove `LimitCollectionCreationDeletionSplit` feature flag (#4809) * Remove references to feature flag * Demote entity property to an EF shadow property * Add a few excludes to license file tests --- .../Organizations/_ViewInformation.cshtml | 16 +- .../Controllers/OrganizationsController.cs | 8 - .../OrganizationResponseModel.cs | 4 - .../ProfileOrganizationResponseModel.cs | 4 - ...rofileProviderOrganizationResponseModel.cs | 2 - ...nCollectionManagementUpdateRequestModel.cs | 17 +- .../BulkCollectionAuthorizationHandler.cs | 54 +- .../AdminConsole/Entities/Organization.cs | 18 - .../Data/Organizations/OrganizationAbility.cs | 4 - .../OrganizationUserOrganizationDetails.cs | 2 - .../SelfHostedOrganizationDetails.cs | 2 - .../ProviderUserOrganizationDetails.cs | 1 - .../Implementations/OrganizationService.cs | 8 - .../OrganizationLicenseClaimsFactory.cs | 6 +- src/Core/Constants.cs | 2 +- .../Models/Business/OrganizationLicense.cs | 2 +- .../AdminConsole/Models/Organization.cs | 6 + .../Repositories/OrganizationRepository.cs | 4 +- ...izationUserOrganizationDetailsViewQuery.cs | 2 - ...roviderUserOrganizationDetailsViewQuery.cs | 2 - ...BulkCollectionAuthorizationHandlerTests.cs | 545 +----------------- .../OrganizationLicenseFileFixtures.cs | 3 +- .../UpdateOrganizationLicenseCommandTests.cs | 11 +- .../OrganizationUserRepositoryTests.cs | 2 - 24 files changed, 74 insertions(+), 651 deletions(-) diff --git a/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml b/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml index f3853e16a9..a0d421235d 100644 --- a/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/_ViewInformation.cshtml @@ -55,19 +55,11 @@
Administrators manage all collections
@(Model.Organization.AllowAdminAccessToAllCollectionItems ? "On" : "Off")
- @if (!FeatureService.IsEnabled(Bit.Core.FeatureFlagKeys.LimitCollectionCreationDeletionSplit)) - { -
Limit collection creation to administrators
-
@(Model.Organization.LimitCollectionCreationDeletion ? "On" : "Off")
- } - else - { -
Limit collection creation to administrators
-
@(Model.Organization.LimitCollectionCreation ? "On" : "Off")
+
Limit collection creation to administrators
+
@(Model.Organization.LimitCollectionCreation ? "On" : "Off")
-
Limit collection deletion to administrators
-
@(Model.Organization.LimitCollectionDeletion ? "On" : "Off")
- } +
Limit collection deletion to administrators
+
@(Model.Organization.LimitCollectionDeletion ? "On" : "Off")

Secrets Manager

diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 6ffd60e425..4421af3a9a 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -526,14 +526,6 @@ public class OrganizationsController : Controller [HttpPut("{id}/collection-management")] public async Task PutCollectionManagement(Guid id, [FromBody] OrganizationCollectionManagementUpdateRequestModel model) { - if ( - _globalSettings.SelfHosted && - !_featureService.IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit) - ) - { - throw new BadRequestException("Only allowed when not self hosted."); - } - var organization = await _organizationRepository.GetByIdAsync(id); if (organization == null) { diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 908a3a9385..116b4b1238 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -57,8 +57,6 @@ public class OrganizationResponseModel : ResponseModel MaxAutoscaleSmServiceAccounts = organization.MaxAutoscaleSmServiceAccounts; LimitCollectionCreation = organization.LimitCollectionCreation; LimitCollectionDeletion = organization.LimitCollectionDeletion; - // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 - LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; UseRiskInsights = organization.UseRiskInsights; } @@ -104,8 +102,6 @@ public class OrganizationResponseModel : ResponseModel public int? MaxAutoscaleSmServiceAccounts { get; set; } public bool LimitCollectionCreation { get; set; } public bool LimitCollectionDeletion { get; set; } - // Deperectated: https://bitwarden.atlassian.net/browse/PM-10863 - public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index 96b86de164..75e4c44a6d 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -67,8 +67,6 @@ public class ProfileOrganizationResponseModel : ResponseModel AccessSecretsManager = organization.AccessSecretsManager; LimitCollectionCreation = organization.LimitCollectionCreation; LimitCollectionDeletion = organization.LimitCollectionDeletion; - // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 - LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; UserIsManagedByOrganization = organizationIdsManagingUser.Contains(organization.OrganizationId); UseRiskInsights = organization.UseRiskInsights; @@ -130,8 +128,6 @@ public class ProfileOrganizationResponseModel : ResponseModel public bool AccessSecretsManager { get; set; } public bool LimitCollectionCreation { get; set; } public bool LimitCollectionDeletion { get; set; } - // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 - public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } /// /// Indicates if the organization manages the user. diff --git a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs index 4ec86a29a4..7227d7a11a 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs @@ -46,8 +46,6 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo ProductTierType = StaticStore.GetPlan(organization.PlanType).ProductTier; LimitCollectionCreation = organization.LimitCollectionCreation; LimitCollectionDeletion = organization.LimitCollectionDeletion; - // https://bitwarden.atlassian.net/browse/PM-10863 - LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; UseRiskInsights = organization.UseRiskInsights; } diff --git a/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs index a5a6f1f74f..94f842ca1e 100644 --- a/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs @@ -1,5 +1,4 @@ -using Bit.Core; -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Services; namespace Bit.Api.Models.Request.Organizations; @@ -8,22 +7,12 @@ public class OrganizationCollectionManagementUpdateRequestModel { public bool LimitCollectionCreation { get; set; } public bool LimitCollectionDeletion { get; set; } - // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 - public bool LimitCreateDeleteOwnerAdmin { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public virtual Organization ToOrganization(Organization existingOrganization, IFeatureService featureService) { - if (featureService.IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit)) - { - existingOrganization.LimitCollectionCreation = LimitCollectionCreation; - existingOrganization.LimitCollectionDeletion = LimitCollectionDeletion; - } - else - { - existingOrganization.LimitCollectionCreationDeletion = LimitCreateDeleteOwnerAdmin || LimitCollectionCreation || LimitCollectionDeletion; - } - + existingOrganization.LimitCollectionCreation = LimitCollectionCreation; + existingOrganization.LimitCollectionDeletion = LimitCollectionDeletion; existingOrganization.AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems; return existingOrganization; } diff --git a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs index c26d5b5952..909064c522 100644 --- a/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs +++ b/src/Api/Vault/AuthorizationHandlers/Collections/BulkCollectionAuthorizationHandler.cs @@ -1,6 +1,5 @@ #nullable enable using System.Diagnostics; -using Bit.Core; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -124,24 +123,15 @@ public class BulkCollectionAuthorizationHandler : BulkAuthorizationHandler, IStorableSubscriber, IRevisable, /// public bool LimitCollectionCreation { get; set; } public bool LimitCollectionDeletion { get; set; } - // Deprecated by https://bitwarden.atlassian.net/browse/PM-10863. This - // was replaced with `LimitCollectionCreation` and - // `LimitCollectionDeletion`. - public bool LimitCollectionCreationDeletion - { - get => LimitCollectionCreation || LimitCollectionDeletion; - set - { - LimitCollectionCreation = value; - LimitCollectionDeletion = value; - } - } /// /// If set to true, admins, owners, and some custom users can read/write all collections and items in the Admin Console. @@ -319,11 +307,5 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable, UseSecretsManager = license.UseSecretsManager; SmSeats = license.SmSeats; SmServiceAccounts = license.SmServiceAccounts; - - if (!featureService.IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit)) - { - LimitCollectionCreationDeletion = license.LimitCollectionCreationDeletion; - AllowAdminAccessToAllCollectionItems = license.AllowAdminAccessToAllCollectionItems; - } } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs index 0da0928dbe..6392e483ce 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs @@ -23,8 +23,6 @@ public class OrganizationAbility UsePolicies = organization.UsePolicies; LimitCollectionCreation = organization.LimitCollectionCreation; LimitCollectionDeletion = organization.LimitCollectionDeletion; - // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 - LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion; AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; UseRiskInsights = organization.UseRiskInsights; } @@ -43,8 +41,6 @@ public class OrganizationAbility public bool UsePolicies { get; set; } public bool LimitCollectionCreation { get; set; } public bool LimitCollectionDeletion { get; set; } - // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 - public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { 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 7b9ea971d3..e06b6bd66a 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -56,8 +56,6 @@ public class OrganizationUserOrganizationDetails public int? SmServiceAccounts { get; set; } public bool LimitCollectionCreation { get; set; } public bool LimitCollectionDeletion { get; set; } - // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 - public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs index 1fa547d98b..bd727f707b 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs @@ -146,8 +146,6 @@ public class SelfHostedOrganizationDetails : Organization OwnersNotifiedOfAutoscaling = OwnersNotifiedOfAutoscaling, LimitCollectionCreation = LimitCollectionCreation, LimitCollectionDeletion = LimitCollectionDeletion, - // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 - LimitCollectionCreationDeletion = LimitCollectionCreationDeletion, AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems, Status = Status }; diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index c2880b543f..f37cc644d4 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -42,7 +42,6 @@ public class ProviderUserOrganizationDetails public PlanType PlanType { get; set; } public bool LimitCollectionCreation { get; set; } public bool LimitCollectionDeletion { get; set; } - public bool LimitCollectionCreationDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } public bool UseRiskInsights { get; set; } } diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 1ca047aa47..eebe76baef 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -581,14 +581,6 @@ public class OrganizationService : IOrganizationService SmServiceAccounts = license.SmServiceAccounts, }; - // These fields are being removed from consideration when processing - // licenses. - if (!_featureService.IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit)) - { - organization.LimitCollectionCreationDeletion = license.LimitCollectionCreationDeletion; - organization.AllowAdminAccessToAllCollectionItems = license.AllowAdminAccessToAllCollectionItems; - } - var result = await SignUpAsync(organization, owner.Id, ownerKey, collectionName, false); var dir = $"{_globalSettings.LicenseDirectory}/organization"; diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs index 300b87dcca..1aac7bb1d8 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -52,7 +52,11 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory Ciphers { get; set; } public virtual ICollection OrganizationUsers { get; set; } public virtual ICollection Groups { get; set; } @@ -38,6 +43,7 @@ public class OrganizationMapperProfile : Profile .ForMember(org => org.ApiKeys, opt => opt.Ignore()) .ForMember(org => org.Connections, opt => opt.Ignore()) .ForMember(org => org.Domains, opt => opt.Ignore()) + .ForMember(org => org.LimitCollectionCreationDeletion, opt => opt.Ignore()) .ReverseMap(); CreateProjection() diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 7a667db8f5..fb3766c6c7 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -101,10 +101,8 @@ public class OrganizationRepository : Repository().DidNotReceiveWithAnyArgs().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanCreateAsync_WhenUser_WithLimitCollectionCreationFalse_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Success( + public async Task CanCreateAsync_WhenUser_WithLimitCollectionCreationFalse_Success( SutProvider sutProvider, ICollection collections, CurrentContextOrganization organization) @@ -62,7 +57,7 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = OrganizationUserType.User; - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, false, false); + ArrangeOrganizationAbility(sutProvider, organization, false, false); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Create }, @@ -71,49 +66,16 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit) - .Returns(false); await sutProvider.Sut.HandleAsync(context); - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); - Assert.True(context.HasSucceeded); - } - - [Theory, BitAutoData, CollectionCustomization] - public async Task CanCreateAsync_WhenUser_WithLimitCollectionCreationFalse_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Success( - SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) - { - var actingUserId = Guid.NewGuid(); - - organization.Type = OrganizationUserType.User; - - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, false, false); - - var context = new AuthorizationHandlerContext( - new[] { BulkCollectionOperations.Create }, - new ClaimsPrincipal(), - collections); - - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit) - .Returns(true); - - await sutProvider.Sut.HandleAsync(context); - - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } [Theory, CollectionCustomization] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] - public async Task CanCreateAsync_WhenMissingPermissions_WithLimitCollectionCreationDeletionSplitFeatureDisabled_NoSuccess( + public async Task CanCreateAsync_WhenMissingPermissions_NoSuccess( OrganizationUserType userType, SutProvider sutProvider, ICollection collections, @@ -130,7 +92,7 @@ public class BulkCollectionAuthorizationHandlerTests ManageUsers = false }; - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true); + ArrangeOrganizationAbility(sutProvider, organization, true, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Create }, @@ -140,61 +102,21 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); await sutProvider.Sut.HandleAsync(context); - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); - Assert.False(context.HasSucceeded); - } - - [Theory, CollectionCustomization] - [BitAutoData(OrganizationUserType.User)] - [BitAutoData(OrganizationUserType.Custom)] - public async Task CanCreateAsync_WhenMissingPermissions_WithLimitCollectionCreationDeletionSplitFeatureEnabled_NoSuccess( - OrganizationUserType userType, - SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) - { - var actingUserId = Guid.NewGuid(); - - organization.Type = userType; - organization.Permissions = new Permissions - { - EditAnyCollection = false, - DeleteAnyCollection = false, - ManageGroups = false, - ManageUsers = false - }; - - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true); - - var context = new AuthorizationHandlerContext( - new[] { BulkCollectionOperations.Create }, - new ClaimsPrincipal(), - collections); - - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); - - await sutProvider.Sut.HandleAsync(context); - - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanCreateAsync_WhenMissingOrgAccess_WithLimitCollectionCreationDeletionSplitDisabled_NoSuccess( + public async Task CanCreateAsync_WhenMissingOrgAccess_NoSuccess( Guid userId, CurrentContextOrganization organization, List collections, SutProvider sutProvider) { collections.ForEach(c => c.OrganizationId = organization.Id); - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true); + ArrangeOrganizationAbility(sutProvider, organization, true, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Create }, @@ -205,38 +127,9 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency().GetOrganization(Arg.Any()).Returns((CurrentContextOrganization)null); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); await sutProvider.Sut.HandleAsync(context); - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); - Assert.False(context.HasSucceeded); - } - - [Theory, BitAutoData, CollectionCustomization] - public async Task CanCreateAsync_WhenMissingOrgAccess_WithLimitCollectionCreationDeletionSplitEnabled_NoSuccess( - Guid userId, - CurrentContextOrganization organization, - List collections, - SutProvider sutProvider) - { - collections.ForEach(c => c.OrganizationId = organization.Id); - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true); - - var context = new AuthorizationHandlerContext( - new[] { BulkCollectionOperations.Create }, - new ClaimsPrincipal(), - collections - ); - - sutProvider.GetDependency().UserId.Returns(userId); - sutProvider.GetDependency().GetOrganization(Arg.Any()).Returns((CurrentContextOrganization)null); - sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); - - await sutProvider.Sut.HandleAsync(context); - - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } @@ -1015,7 +908,7 @@ public class BulkCollectionAuthorizationHandlerTests // `LimitCollectonCreationDeletionSplit` feature flag state isn't // relevant for this test. The flag is never checked for in this // test. This is asserted below. - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true); + ArrangeOrganizationAbility(sutProvider, organization, true, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Delete }, @@ -1027,7 +920,6 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); - sutProvider.GetDependency().DidNotReceiveWithAnyArgs().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } @@ -1046,7 +938,7 @@ public class BulkCollectionAuthorizationHandlerTests // `LimitCollectonCreationDeletionSplit` feature flag state isn't // relevant for this test. The flag is never checked for in this // test. This is asserted below. - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true); + ArrangeOrganizationAbility(sutProvider, organization, true, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Delete }, @@ -1058,12 +950,11 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); - sutProvider.GetDependency().DidNotReceiveWithAnyArgs().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionFalse_WithCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Success( + public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionFalse_WithCanManagePermission_Success( SutProvider sutProvider, ICollection collections, CurrentContextOrganization organization) @@ -1073,12 +964,11 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = OrganizationUserType.User; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, false, false); + ArrangeOrganizationAbility(sutProvider, organization, false, false); sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); foreach (var c in collections) { @@ -1092,41 +982,6 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); - Assert.True(context.HasSucceeded); - } - - [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionFalse_WithCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Success( - SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) - { - var actingUserId = Guid.NewGuid(); - - organization.Type = OrganizationUserType.User; - organization.Permissions = new Permissions(); - - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, false, false); - - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); - - foreach (var c in collections) - { - c.Manage = true; - } - - var context = new AuthorizationHandlerContext( - new[] { BulkCollectionOperations.Delete }, - new ClaimsPrincipal(), - collections); - - await sutProvider.Sut.HandleAsync(context); - - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } @@ -1134,7 +989,7 @@ public class BulkCollectionAuthorizationHandlerTests [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.Owner)] [BitAutoData(OrganizationUserType.User)] - public async Task CanDeleteAsync_LimitCollectionDeletionFalse_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Success( + public async Task CanDeleteAsync_LimitCollectionDeletionFalse_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_Success( OrganizationUserType userType, SutProvider sutProvider, ICollection collections, @@ -1145,12 +1000,11 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, false, false, false); + ArrangeOrganizationAbility(sutProvider, organization, false, false, false); sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); foreach (var c in collections) { @@ -1164,15 +1018,13 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } [Theory, CollectionCustomization] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.Owner)] - [BitAutoData(OrganizationUserType.User)] - public async Task CanDeleteAsync_LimitCollectionDeletionFalse_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Success( + public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_Success( OrganizationUserType userType, SutProvider sutProvider, ICollection collections, @@ -1183,12 +1035,11 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, false, false, false); + ArrangeOrganizationAbility(sutProvider, organization, true, true, false); sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); foreach (var c in collections) { @@ -1202,14 +1053,13 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.True(context.HasSucceeded); } [Theory, CollectionCustomization] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.Owner)] - public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Success( + public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithoutCanManagePermission_Failure( OrganizationUserType userType, SutProvider sutProvider, ICollection collections, @@ -1220,87 +1070,12 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = userType; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true, false); - - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); - - foreach (var c in collections) - { - c.Manage = true; - } - - var context = new AuthorizationHandlerContext( - new[] { BulkCollectionOperations.Delete }, - new ClaimsPrincipal(), - collections); - - await sutProvider.Sut.HandleAsync(context); - - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); - Assert.True(context.HasSucceeded); - } - - [Theory, CollectionCustomization] - [BitAutoData(OrganizationUserType.Admin)] - [BitAutoData(OrganizationUserType.Owner)] - public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Success( - OrganizationUserType userType, - SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) - { - var actingUserId = Guid.NewGuid(); - - organization.Type = userType; - organization.Permissions = new Permissions(); - - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true, false); - - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); - - foreach (var c in collections) - { - c.Manage = true; - } - - var context = new AuthorizationHandlerContext( - new[] { BulkCollectionOperations.Delete }, - new ClaimsPrincipal(), - collections); - - await sutProvider.Sut.HandleAsync(context); - - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); - Assert.True(context.HasSucceeded); - } - - [Theory, CollectionCustomization] - [BitAutoData(OrganizationUserType.Admin)] - [BitAutoData(OrganizationUserType.Owner)] - public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithoutCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Failure( - OrganizationUserType userType, - SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) - { - var actingUserId = Guid.NewGuid(); - - organization.Type = userType; - organization.Permissions = new Permissions(); - - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true, false); + ArrangeOrganizationAbility(sutProvider, organization, true, true, false); sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); foreach (var c in collections) { @@ -1314,50 +1089,11 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); - Assert.False(context.HasSucceeded); - } - - [Theory, CollectionCustomization] - [BitAutoData(OrganizationUserType.Admin)] - [BitAutoData(OrganizationUserType.Owner)] - public async Task CanDeleteAsync_WhenAdminOrOwner_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithoutCanManagePermission_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Failure( - OrganizationUserType userType, - SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) - { - var actingUserId = Guid.NewGuid(); - - organization.Type = userType; - organization.Permissions = new Permissions(); - - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true, false); - - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); - sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); - - foreach (var c in collections) - { - c.Manage = false; - } - - var context = new AuthorizationHandlerContext( - new[] { BulkCollectionOperations.Delete }, - new ClaimsPrincipal(), - collections); - - await sutProvider.Sut.HandleAsync(context); - - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsTrue_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Failure( + public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsTrue_Failure( SutProvider sutProvider, ICollection collections, CurrentContextOrganization organization) @@ -1367,13 +1103,12 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = OrganizationUserType.User; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true); + ArrangeOrganizationAbility(sutProvider, organization, true, true); sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); foreach (var c in collections) { @@ -1387,12 +1122,11 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsTrue_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Failure( + public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_Failure( SutProvider sutProvider, ICollection collections, CurrentContextOrganization organization) @@ -1402,13 +1136,12 @@ public class BulkCollectionAuthorizationHandlerTests organization.Type = OrganizationUserType.User; organization.Permissions = new Permissions(); - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true); + ArrangeOrganizationAbility(sutProvider, organization, true, true, false); sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); foreach (var c in collections) { @@ -1422,88 +1155,13 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); - Assert.False(context.HasSucceeded); - } - - [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Failure( - SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) - { - var actingUserId = Guid.NewGuid(); - - organization.Type = OrganizationUserType.User; - organization.Permissions = new Permissions(); - - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true, false); - - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); - sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit) - .Returns(false); - - foreach (var c in collections) - { - c.Manage = true; - } - - var context = new AuthorizationHandlerContext( - new[] { BulkCollectionOperations.Delete }, - new ClaimsPrincipal(), - collections); - - await sutProvider.Sut.HandleAsync(context); - - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); - Assert.False(context.HasSucceeded); - } - - [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WhenUser_LimitCollectionDeletionTrue_AllowAdminAccessToAllCollectionItemsFalse_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Failure( - SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) - { - var actingUserId = Guid.NewGuid(); - - organization.Type = OrganizationUserType.User; - organization.Permissions = new Permissions(); - - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true, false); - - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().GetManyByUserIdAsync(actingUserId).Returns(collections); - sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit) - .Returns(true); - - foreach (var c in collections) - { - c.Manage = true; - } - - var context = new AuthorizationHandlerContext( - new[] { BulkCollectionOperations.Delete }, - new ClaimsPrincipal(), - collections); - - await sutProvider.Sut.HandleAsync(context); - - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } [Theory, CollectionCustomization] [BitAutoData(OrganizationUserType.User)] [BitAutoData(OrganizationUserType.Custom)] - public async Task CanDeleteAsync_WhenMissingPermissions_WithLimitCollectionCreationDeletionSplitFeatureDisabled_NoSuccess( + public async Task CanDeleteAsync_WhenMissingPermissions_NoSuccess( OrganizationUserType userType, SutProvider sutProvider, ICollection collections, @@ -1520,7 +1178,7 @@ public class BulkCollectionAuthorizationHandlerTests ManageUsers = false }; - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled(sutProvider, organization, true, true); + ArrangeOrganizationAbility(sutProvider, organization, true, true); var context = new AuthorizationHandlerContext( new[] { BulkCollectionOperations.Delete }, @@ -1530,54 +1188,14 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(actingUserId); sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); await sutProvider.Sut.HandleAsync(context); - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); - Assert.False(context.HasSucceeded); - } - - [Theory, CollectionCustomization] - [BitAutoData(OrganizationUserType.User)] - [BitAutoData(OrganizationUserType.Custom)] - public async Task CanDeleteAsync_WhenMissingPermissions_WithLimitCollectionCreationDeletionSplitFeatureEnabled_NoSuccess( - OrganizationUserType userType, - SutProvider sutProvider, - ICollection collections, - CurrentContextOrganization organization) - { - var actingUserId = Guid.NewGuid(); - - organization.Type = userType; - organization.Permissions = new Permissions - { - EditAnyCollection = false, - DeleteAnyCollection = false, - ManageGroups = false, - ManageUsers = false - }; - - ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled(sutProvider, organization, true, true); - - var context = new AuthorizationHandlerContext( - new[] { BulkCollectionOperations.Delete }, - new ClaimsPrincipal(), - collections); - - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); - sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); - - await sutProvider.Sut.HandleAsync(context); - - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WhenMissingOrgAccess_WithLimitCollectionCreationDeletionSplitFeatureDisabled_NoSuccess( + public async Task CanDeleteAsync_WhenMissingOrgAccess_NoSuccess( Guid userId, ICollection collections, SutProvider sutProvider) @@ -1591,34 +1209,9 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().UserId.Returns(userId); sutProvider.GetDependency().GetOrganization(Arg.Any()).Returns((CurrentContextOrganization)null); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); await sutProvider.Sut.HandleAsync(context); - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); - Assert.False(context.HasSucceeded); - } - - [Theory, BitAutoData, CollectionCustomization] - public async Task CanDeleteAsync_WhenMissingOrgAccess_WithLimitCollectionCreationDeletionSplitFeatureEnabled_NoSuccess( - Guid userId, - ICollection collections, - SutProvider sutProvider) - { - var context = new AuthorizationHandlerContext( - new[] { BulkCollectionOperations.Delete }, - new ClaimsPrincipal(), - collections - ); - - sutProvider.GetDependency().UserId.Returns(userId); - sutProvider.GetDependency().GetOrganization(Arg.Any()).Returns((CurrentContextOrganization)null); - sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(false); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); - - await sutProvider.Sut.HandleAsync(context); - - sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); Assert.False(context.HasSucceeded); } @@ -1639,7 +1232,6 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.Sut.HandleAsync(context); Assert.True(context.HasFailed); sutProvider.GetDependency().DidNotReceiveWithAnyArgs(); - sutProvider.GetDependency().DidNotReceiveWithAnyArgs().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); } [Theory, BitAutoData, CollectionCustomization] @@ -1663,66 +1255,10 @@ public class BulkCollectionAuthorizationHandlerTests var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.HandleAsync(context)); Assert.Equal("Requested collections must belong to the same organization.", exception.Message); sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetOrganization(default); - sutProvider.GetDependency().DidNotReceiveWithAnyArgs().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit); } [Theory, BitAutoData, CollectionCustomization] - public async Task HandleRequirementAsync_Provider_WithLimitCollectionCreationDeletionSplitFeatureDisabled_Success( - SutProvider sutProvider, - ICollection collections) - { - var actingUserId = Guid.NewGuid(); - var orgId = collections.First().OrganizationId; - - var organizationAbilities = new Dictionary - { - { collections.First().OrganizationId, - new OrganizationAbility - { - LimitCollectionCreationDeletion = true, - AllowAdminAccessToAllCollectionItems = true - } - } - }; - - var operationsToTest = new[] - { - BulkCollectionOperations.Create, - BulkCollectionOperations.Read, - BulkCollectionOperations.ReadAccess, - BulkCollectionOperations.Update, - BulkCollectionOperations.ModifyUserAccess, - BulkCollectionOperations.ModifyGroupAccess, - BulkCollectionOperations.Delete, - }; - - foreach (var op in operationsToTest) - { - sutProvider.GetDependency().UserId.Returns(actingUserId); - sutProvider.GetDependency().GetOrganization(orgId).Returns((CurrentContextOrganization)null); - sutProvider.GetDependency().GetOrganizationAbilitiesAsync() - .Returns(organizationAbilities); - sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(true); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(false); - - var context = new AuthorizationHandlerContext( - new[] { op }, - new ClaimsPrincipal(), - collections - ); - - await sutProvider.Sut.HandleAsync(context); - - Assert.True(context.HasSucceeded); - await sutProvider.GetDependency().Received().ProviderUserForOrgAsync(orgId); - - // Recreate the SUT to reset the mocks/dependencies between tests - sutProvider.Recreate(); - } - } - - [Theory, BitAutoData, CollectionCustomization] - public async Task HandleRequirementAsync_Provider_WithLimitCollectionCreationDeletionSplitFeatureEnabled_Success( + public async Task HandleRequirementAsync_Provider_Success( SutProvider sutProvider, ICollection collections) { @@ -1759,7 +1295,6 @@ public class BulkCollectionAuthorizationHandlerTests sutProvider.GetDependency().GetOrganizationAbilitiesAsync() .Returns(organizationAbilities); sutProvider.GetDependency().ProviderUserForOrgAsync(Arg.Any()).Returns(true); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.LimitCollectionCreationDeletionSplit).Returns(true); var context = new AuthorizationHandlerContext( new[] { op }, @@ -1810,30 +1345,12 @@ public class BulkCollectionAuthorizationHandlerTests await sutProvider.GetDependency().Received(1).GetManyByUserIdAsync(Arg.Any()); } - private static void ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureDisabled( - SutProvider sutProvider, - CurrentContextOrganization organization, - bool limitCollectionCreation, - bool limitCollectionDeletion, - bool allowAdminAccessToAllCollectionItems = true) - { - var organizationAbility = new OrganizationAbility(); - organizationAbility.Id = organization.Id; - - organizationAbility.LimitCollectionCreationDeletion = limitCollectionCreation || limitCollectionDeletion; - - organizationAbility.AllowAdminAccessToAllCollectionItems = allowAdminAccessToAllCollectionItems; - - sutProvider.GetDependency().GetOrganizationAbilityAsync(organizationAbility.Id) - .Returns(organizationAbility); - } - - private static void ArrangeOrganizationAbility_WithLimitCollectionCreationDeletionSplitFeatureEnabled( - SutProvider sutProvider, - CurrentContextOrganization organization, - bool limitCollectionCreation, - bool limitCollectionDeletion, - bool allowAdminAccessToAllCollectionItems = true) + private static void ArrangeOrganizationAbility( + SutProvider sutProvider, + CurrentContextOrganization organization, + bool limitCollectionCreation, + bool limitCollectionDeletion, + bool allowAdminAccessToAllCollectionItems = true) { var organizationAbility = new OrganizationAbility(); organizationAbility.Id = organization.Id; diff --git a/test/Core.Test/Models/Business/OrganizationLicenseFileFixtures.cs b/test/Core.Test/Models/Business/OrganizationLicenseFileFixtures.cs index 500c4475a9..de5fb25fca 100644 --- a/test/Core.Test/Models/Business/OrganizationLicenseFileFixtures.cs +++ b/test/Core.Test/Models/Business/OrganizationLicenseFileFixtures.cs @@ -111,7 +111,8 @@ public static class OrganizationLicenseFileFixtures SmServiceAccounts = 8, MaxAutoscaleSmSeats = 101, MaxAutoscaleSmServiceAccounts = 102, - LimitCollectionCreationDeletion = true, + LimitCollectionCreation = true, + LimitCollectionDeletion = true, AllowAdminAccessToAllCollectionItems = true, }; } diff --git a/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs index b8e677177c..420d330aaa 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationLicenses/UpdateOrganizationLicenseCommandTests.cs @@ -83,11 +83,12 @@ public class UpdateOrganizationLicenseCommandTests .Received(1) .ReplaceAndUpdateCacheAsync(Arg.Is( org => AssertPropertyEqual(license, org, - "Id", "MaxStorageGb", "Issued", "Refresh", "Version", "Trial", "LicenseType", - "Hash", "Signature", "SignatureBytes", "InstallationId", "Expires", - "ExpirationWithoutGracePeriod", "Token") && - // Same property but different name, use explicit mapping - org.ExpirationDate == license.Expires)); + "Id", "MaxStorageGb", "Issued", "Refresh", "Version", "Trial", "LicenseType", + "Hash", "Signature", "SignatureBytes", "InstallationId", "Expires", + "ExpirationWithoutGracePeriod", "Token", "LimitCollectionCreationDeletion", + "LimitCollectionCreation", "LimitCollectionDeletion", "AllowAdminAccessToAllCollectionItems") && + // Same property but different name, use explicit mapping + org.ExpirationDate == license.Expires)); } finally { diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs index 4732d8a474..aee4beb8ce 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepositoryTests.cs @@ -255,8 +255,6 @@ public class OrganizationUserRepositoryTests Assert.Equal(organization.SmServiceAccounts, result.SmServiceAccounts); Assert.Equal(organization.LimitCollectionCreation, result.LimitCollectionCreation); Assert.Equal(organization.LimitCollectionDeletion, result.LimitCollectionDeletion); - // Deprecated: https://bitwarden.atlassian.net/browse/PM-10863 - Assert.Equal(organization.LimitCollectionCreationDeletion, result.LimitCollectionCreationDeletion); Assert.Equal(organization.AllowAdminAccessToAllCollectionItems, result.AllowAdminAccessToAllCollectionItems); Assert.Equal(organization.UseRiskInsights, result.UseRiskInsights); } From 9ebddd223acc6e0408e30caf6d24213873369b69 Mon Sep 17 00:00:00 2001 From: Opeyemi Date: Fri, 6 Dec 2024 16:53:52 +0000 Subject: [PATCH 28/30] [BRE-470] - Update Renovate Conf for BRE team (#5123) --- .github/renovate.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index ac08134041..5779b28edb 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -26,7 +26,7 @@ }, { "matchManagers": ["github-actions", "dockerfile", "docker-compose"], - "commitMessagePrefix": "[deps] DevOps:" + "commitMessagePrefix": "[deps] BRE:" }, { "matchPackageNames": ["DnsClient"], @@ -116,8 +116,8 @@ { "matchPackageNames": ["CommandDotNet", "YamlDotNet"], "description": "DevOps owned dependencies", - "commitMessagePrefix": "[deps] DevOps:", - "reviewers": ["team:dept-devops"] + "commitMessagePrefix": "[deps] BRE:", + "reviewers": ["team:dept-bre"] }, { "matchPackageNames": [ From fb5db40f4c8fa6b2324c85280935525ae827f03e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=9F=E6=AD=A6=2E=E5=B0=BC=E5=BE=B7=E9=9C=8D=E6=A0=BC?= =?UTF-8?q?=2E=E9=BE=8D?= <7708801314520.tony@gmail.com> Date: Sat, 7 Dec 2024 02:34:50 +0800 Subject: [PATCH 29/30] Update docker reference link (#5096) Update docker reference link Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- util/Setup/Templates/DockerCompose.hbs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/util/Setup/Templates/DockerCompose.hbs b/util/Setup/Templates/DockerCompose.hbs index d9ad6c4613..ffe9121089 100644 --- a/util/Setup/Templates/DockerCompose.hbs +++ b/util/Setup/Templates/DockerCompose.hbs @@ -1,8 +1,8 @@ # # Useful references: -# https://docs.docker.com/compose/compose-file/ -# https://docs.docker.com/compose/reference/overview/#use--f-to-specify-name-and-path-of-one-or-more-compose-files -# https://docs.docker.com/compose/reference/envvars/ +# https://docs.docker.com/reference/compose-file/ +# https://docs.docker.com/reference/cli/docker/compose/#use--f-to-specify-the-name-and-path-of-one-or-more-compose-files +# https://docs.docker.com/compose/how-tos/environment-variables/envvars/ # ######################################################################### # WARNING: This file is generated. Do not make changes to this file. # From c591997d0136a6126308444068cfa89f604c531f Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Fri, 6 Dec 2024 14:40:47 -0500 Subject: [PATCH 30/30] [PM-13013] add delete many async method to i user repository and i user service for bulk user deletion (#5035) * Add DeleteManyAsync method and stored procedure * Add DeleteManyAsync and tests * removed stored procedure, refactor User_DeleteById to accept multiple Ids * add sproc, refactor tests * revert existing sproc * add bulk delete to IUserService * fix sproc * fix and add tests * add migration script, fix test * Add feature flag * add feature flag to tests for deleteManyAsync * enable nullable, delete only user that pass validation * revert changes to DeleteAsync * Cleanup whitespace * remove redundant feature flag * fix tests * move DeleteManyAsync from UserService into DeleteManagedOrganizationUserAccountCommand * refactor validation, remove unneeded tasks * refactor tests, remove unused service --- ...teManagedOrganizationUserAccountCommand.cs | 86 +++++++++- src/Core/Repositories/IUserRepository.cs | 1 + .../Repositories/UserRepository.cs | 12 ++ .../Repositories/UserRepository.cs | 47 ++++++ .../Stored Procedures/User_DeleteByIds.sql | 158 ++++++++++++++++++ ...agedOrganizationUserAccountCommandTests.cs | 12 +- .../Auth/Repositories/UserRepositoryTests.cs | 99 +++++++++++ .../2024-11-22_00_UserDeleteByIds.sql | 158 ++++++++++++++++++ 8 files changed, 565 insertions(+), 8 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql create mode 100644 test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs create mode 100644 util/Migrator/DbScripts/2024-11-22_00_UserDeleteByIds.sql diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs index 0bcd16cee1..cb7e2a6250 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommand.cs @@ -1,10 +1,14 @@ 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.Repositories; using Bit.Core.Services; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Business; +using Bit.Core.Tools.Services; #nullable enable @@ -19,7 +23,10 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz private readonly IUserRepository _userRepository; private readonly ICurrentContext _currentContext; private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; - + private readonly IReferenceEventService _referenceEventService; + private readonly IPushNotificationService _pushService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IProviderUserRepository _providerUserRepository; public DeleteManagedOrganizationUserAccountCommand( IUserService userService, IEventService eventService, @@ -27,7 +34,11 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz IOrganizationUserRepository organizationUserRepository, IUserRepository userRepository, ICurrentContext currentContext, - IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) + IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, + IReferenceEventService referenceEventService, + IPushNotificationService pushService, + IOrganizationRepository organizationRepository, + IProviderUserRepository providerUserRepository) { _userService = userService; _eventService = eventService; @@ -36,6 +47,10 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz _userRepository = userRepository; _currentContext = currentContext; _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; + _referenceEventService = referenceEventService; + _pushService = pushService; + _organizationRepository = organizationRepository; + _providerUserRepository = providerUserRepository; } public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId) @@ -89,7 +104,8 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz throw new NotFoundException("Member not found."); } - await _userService.DeleteAsync(user); + await ValidateUserMembershipAndPremiumAsync(user); + results.Add((orgUserId, string.Empty)); } catch (Exception ex) @@ -98,6 +114,15 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz } } + 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; @@ -158,4 +183,59 @@ public class DeleteManagedOrganizationUserAccountCommand : IDeleteManagedOrganiz await _eventService.LogOrganizationUserEventsAsync(events); } } + private async Task DeleteManyAsync(IEnumerable users) + { + + await _userRepository.DeleteManyAsync(users); + foreach (var user in users) + { + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.DeleteAccount, user, _currentContext)); + 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/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 22e2ec1a07..040e6e1f49 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -32,4 +32,5 @@ public interface IUserRepository : IRepository /// Registered database calls to update re-encrypted data. Task UpdateUserKeyAndEncryptedDataAsync(User user, IEnumerable updateDataActions); + Task DeleteManyAsync(IEnumerable users); } diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index 9e613fdf08..227a7c03e5 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -172,6 +172,18 @@ public class UserRepository : Repository, IUserRepository commandTimeout: 180); } } + public async Task DeleteManyAsync(IEnumerable users) + { + var ids = users.Select(user => user.Id); + using (var connection = new SqlConnection(ConnectionString)) + { + await connection.ExecuteAsync( + $"[{Schema}].[{Table}_DeleteByIds]", + new { Ids = JsonSerializer.Serialize(ids) }, + commandType: CommandType.StoredProcedure, + commandTimeout: 180); + } + } public async Task UpdateStorageAsync(Guid id) { diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index d234d25455..cbfefb6483 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -261,6 +261,53 @@ public class UserRepository : Repository, IUserR var mappedUser = Mapper.Map(user); dbContext.Users.Remove(mappedUser); + await transaction.CommitAsync(); + await dbContext.SaveChangesAsync(); + } + } + + public async Task DeleteManyAsync(IEnumerable users) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var transaction = await dbContext.Database.BeginTransactionAsync(); + + var targetIds = users.Select(u => u.Id).ToList(); + + 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.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(); + await dbContext.ProviderUsers.Where(pu => targetIds.Contains(pu.UserId ?? default)).ExecuteDeleteAsync(); + await dbContext.SsoUsers.Where(su => targetIds.Contains(su.UserId)).ExecuteDeleteAsync(); + await dbContext.EmergencyAccesses.Where(ea => targetIds.Contains(ea.GrantorId) || targetIds.Contains(ea.GranteeId ?? default)).ExecuteDeleteAsync(); + await dbContext.Sends.Where(s => targetIds.Contains(s.UserId ?? default)).ExecuteDeleteAsync(); + 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 transaction.CommitAsync(); await dbContext.SaveChangesAsync(); } diff --git a/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql b/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql new file mode 100644 index 0000000000..97ab955f83 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql @@ -0,0 +1,158 @@ +CREATE 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) + + -- 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 diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs index 81e83d7450..b21ae5459f 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteManagedOrganizationUserAccountCommandTests.cs @@ -258,14 +258,15 @@ public class DeleteManagedOrganizationUserAccountCommandTests .Returns(new Dictionary { { orgUser1.Id, true }, { orgUser2.Id, true } }); // Act - var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, new[] { orgUser1.Id, orgUser2.Id }, null); + 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).DeleteAsync(user1); - await sutProvider.GetDependency().Received(1).DeleteAsync(user2); + 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 @@ -286,7 +287,9 @@ public class DeleteManagedOrganizationUserAccountCommandTests Assert.Single(result); Assert.Equal(orgUserId, result.First().Item1); Assert.Contains("Member not found.", result.First().Item2); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteManyAsync(default); await sutProvider.GetDependency().Received(0) .LogOrganizationUserEventsAsync(Arg.Any>()); } @@ -484,7 +487,6 @@ public class DeleteManagedOrganizationUserAccountCommandTests Assert.Equal("You cannot delete a member with Invited status.", results.First(r => r.Item1 == orgUser2.Id).Item2); Assert.Equal("Member is not managed by the organization.", results.First(r => r.Item1 == orgUser3.Id).Item2); - await sutProvider.GetDependency().Received(1).DeleteAsync(user1); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync( Arg.Is>(events => events.Count(e => e.Item1.Id == orgUser1.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1)); diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs new file mode 100644 index 0000000000..d4606ae632 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs @@ -0,0 +1,99 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Repositories; + +public class UserRepositoryTests +{ + [DatabaseTheory, DatabaseData] + public async Task DeleteAsync_Works(IUserRepository userRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + await userRepository.DeleteAsync(user); + + var deletedUser = await userRepository.GetByIdAsync(user.Id); + Assert.Null(deletedUser); + } + + [DatabaseTheory, DatabaseData] + public async Task DeleteManyAsync_Works(IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository) + { + var user1 = await userRepository.CreateAsync(new User + { + Name = "Test User 1", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var user2 = await userRepository.CreateAsync(new User + { + Name = "Test User 2", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var user3 = await userRepository.CreateAsync(new User + { + Name = "Test User 3", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = user3.Email, // TODO: EF does not enfore this being NOT NULL + Plan = "Test", // TODO: EF does not enforce this being NOT NULl + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user1.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user3.Id, + Status = OrganizationUserStatusType.Confirmed, + }); + + await userRepository.DeleteManyAsync(new List + { + user1, + user2 + }); + + var deletedUser1 = await userRepository.GetByIdAsync(user1.Id); + var deletedUser2 = await userRepository.GetByIdAsync(user2.Id); + var notDeletedUser3 = await userRepository.GetByIdAsync(user3.Id); + + var orgUser1Deleted = await organizationUserRepository.GetByIdAsync(user1.Id); + + var notDeletedOrgUsers = await organizationUserRepository.GetManyByUserAsync(user3.Id); + + Assert.Null(deletedUser1); + Assert.Null(deletedUser2); + Assert.NotNull(notDeletedUser3); + + Assert.Null(orgUser1Deleted); + Assert.NotNull(notDeletedOrgUsers); + Assert.True(notDeletedOrgUsers.Count > 0); + } + +} diff --git a/util/Migrator/DbScripts/2024-11-22_00_UserDeleteByIds.sql b/util/Migrator/DbScripts/2024-11-22_00_UserDeleteByIds.sql new file mode 100644 index 0000000000..244151143e --- /dev/null +++ b/util/Migrator/DbScripts/2024-11-22_00_UserDeleteByIds.sql @@ -0,0 +1,158 @@ +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) + + -- 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