diff --git a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs index 3abff7f4de4d..0f3f06fd1978 100644 --- a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs +++ b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs @@ -11,7 +11,7 @@ namespace Bit.Core.AdminConsole.Repositories; public interface IPolicyRepository : IRepository { /// - /// Gets all policies of a given type for an organization. + /// Gets all policies of a given type for an organization where the user is in the Confirmed status. /// /// /// WARNING: do not use this to enforce policies against a user! It returns raw data and does not take into account @@ -19,8 +19,21 @@ public interface IPolicyRepository : IRepository /// Task GetByOrganizationIdTypeAsync(Guid organizationId, PolicyType type); Task> GetManyByOrganizationIdAsync(Guid organizationId); + + /// + /// Gets all policies for a user across organizations where the user is in the Confirmed status. + /// Task> GetManyByUserIdAsync(Guid userId); + /// + /// Gets all policies for a user across organizations where the user is in the Confirmed or Accepted status. + /// + /// + /// WARNING: do not use this to enforce policies against a user! It returns raw data and does not take into account + /// various business rules. Use instead. + /// + Task> GetManyConfirmedAcceptedByUserIdAsync(Guid userId); + /// /// Retrieves of the specified /// for users in the given organization and for any other organizations those users belong to. diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index ad9fe2c7ed77..0c9c883c3dea 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -293,7 +293,8 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id, EndDate = phase1.EndDate, Items = phase1.Items.Select(i => new SubscriptionSchedulePhaseItemOptions { - Price = i.PriceId, Quantity = i.Quantity + Price = i.PriceId, + Quantity = i.Quantity }).ToList(), Discounts = phase1.Discounts?.Select(d => new SubscriptionSchedulePhaseDiscountOptions { Coupon = d.CouponId }).ToList(), diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs index f64e5fa94408..807a6d8ded1a 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs @@ -61,6 +61,19 @@ public async Task> GetManyByUserIdAsync(Guid userId) } } + public async Task> GetManyConfirmedAcceptedByUserIdAsync(Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadConfirmedAcceptedByUserId]", + new { UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + public async Task> GetPolicyDetailsByUserIdsAndPolicyType(IEnumerable userIds, PolicyType type) { await using var connection = new SqlConnection(ConnectionString); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs index 12f7abc50bb2..c22129db8c82 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs @@ -56,6 +56,18 @@ public PolicyRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper } } + public async Task> GetManyConfirmedAcceptedByUserIdAsync(Guid userId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var query = new PolicyReadByUserIdConfirmedAndAcceptedQuery(userId); + var results = await query.Run(dbContext).ToListAsync(); + return Mapper.Map>(results); + } + } + public async Task> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) { using var scope = ServiceScopeFactory.CreateScope(); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/PolicyReadByUserIdConfirmedAndAcceptedQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/PolicyReadByUserIdConfirmedAndAcceptedQuery.cs new file mode 100644 index 000000000000..9d6a4969c5ef --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/PolicyReadByUserIdConfirmedAndAcceptedQuery.cs @@ -0,0 +1,31 @@ +using Bit.Core.Enums; +using Bit.Infrastructure.EntityFramework.AdminConsole.Models; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories.Queries; + +namespace Bit.Infrastructure.EntityFramework.AdminConsole.Repositories.Queries; + +public class PolicyReadByUserIdConfirmedAndAcceptedQuery : IQuery +{ + private readonly Guid _userId; + + public PolicyReadByUserIdConfirmedAndAcceptedQuery(Guid userId) + { + _userId = userId; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var query = from p in dbContext.Policies + join ou in dbContext.OrganizationUsers + on p.OrganizationId equals ou.OrganizationId + join o in dbContext.Organizations + on ou.OrganizationId equals o.Id + where ou.UserId == _userId && + (ou.Status == OrganizationUserStatusType.Confirmed || + ou.Status == OrganizationUserStatusType.Accepted) + select p; + + return query; + } +} diff --git a/src/Sql/dbo/Stored Procedures/Policy_ReadConfirmedAcceptedByUserId.sql b/src/Sql/dbo/Stored Procedures/Policy_ReadConfirmedAcceptedByUserId.sql new file mode 100644 index 000000000000..7cd7b080b657 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Policy_ReadConfirmedAcceptedByUserId.sql @@ -0,0 +1,18 @@ +CREATE PROCEDURE [dbo].[Policy_ReadConfirmedAcceptedByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + P.* + FROM + [dbo].[PolicyView] P + INNER JOIN + [dbo].[OrganizationUser] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN + [dbo].[Organization] O ON OU.[OrganizationId] = O.[Id] + WHERE + OU.[UserId] = @UserId + AND OU.[Status] IN (1, 2) -- 1 = Accepted, 2 = Confirmed +END diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetManyConfirmedAcceptedByUserIdAsyncTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetManyConfirmedAcceptedByUserIdAsyncTests.cs new file mode 100644 index 000000000000..00ed72d2fb2c --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetManyConfirmedAcceptedByUserIdAsyncTests.cs @@ -0,0 +1,207 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.PolicyRepository; + +public class GetManyConfirmedAcceptedByUserIdAsyncTests +{ + [Theory, DatabaseData] + public async Task ReturnsPolicies_WhenUserIsConfirmed( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var organization = await organizationRepository.CreateTestOrganizationAsync(); + await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user); + var policy = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.TwoFactorAuthentication, + Enabled = true + }); + + // Act + var results = await policyRepository.GetManyConfirmedAcceptedByUserIdAsync(user.Id); + + // Assert + Assert.Contains(results, p => p.Id == policy.Id); + + // Annul + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteAsync(user); + } + + [Theory, DatabaseData] + public async Task ReturnsPolicies_WhenUserIsAccepted( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var organization = await organizationRepository.CreateTestOrganizationAsync(); + await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user); + var policy = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.TwoFactorAuthentication, + Enabled = true + }); + + // Act + var results = await policyRepository.GetManyConfirmedAcceptedByUserIdAsync(user.Id); + + // Assert + Assert.Contains(results, p => p.Id == policy.Id); + + // Annul + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteAsync(user); + } + + [Theory, DatabaseData] + public async Task ReturnsPoliciesAcrossMultipleOrganizations_WhenUserIsConfirmedOrAccepted( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + + var confirmedOrg = await organizationRepository.CreateTestOrganizationAsync(); + await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(confirmedOrg, user); + var confirmedPolicy = await policyRepository.CreateAsync(new Policy + { + OrganizationId = confirmedOrg.Id, + Type = PolicyType.TwoFactorAuthentication, + Enabled = true + }); + + var acceptedOrg = await organizationRepository.CreateTestOrganizationAsync(); + await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(acceptedOrg, user); + var acceptedPolicy = await policyRepository.CreateAsync(new Policy + { + OrganizationId = acceptedOrg.Id, + Type = PolicyType.TwoFactorAuthentication, + Enabled = true + }); + + // Act + var results = await policyRepository.GetManyConfirmedAcceptedByUserIdAsync(user.Id); + + // Assert + Assert.Contains(results, p => p.Id == confirmedPolicy.Id); + Assert.Contains(results, p => p.Id == acceptedPolicy.Id); + + // Annul + await organizationRepository.DeleteAsync(confirmedOrg); + await organizationRepository.DeleteAsync(acceptedOrg); + await userRepository.DeleteAsync(user); + } + + [Theory, DatabaseData] + public async Task DoesNotReturnPolicies_WhenUserIsInvited( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var organization = await organizationRepository.CreateTestOrganizationAsync(); + await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organization.Id, + UserId = null, + Email = user.Email, + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User + }); + var policy = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.TwoFactorAuthentication, + Enabled = true + }); + + // Act + var results = await policyRepository.GetManyConfirmedAcceptedByUserIdAsync(user.Id); + + // Assert + Assert.DoesNotContain(results, p => p.Id == policy.Id); + + // Annul + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteAsync(user); + } + + [Theory, DatabaseData] + public async Task DoesNotReturnPolicies_WhenUserIsRevoked( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var organization = await organizationRepository.CreateTestOrganizationAsync(); + await organizationUserRepository.CreateRevokedTestOrganizationUserAsync(organization, user); + var policy = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.TwoFactorAuthentication, + Enabled = true + }); + + // Act + var results = await policyRepository.GetManyConfirmedAcceptedByUserIdAsync(user.Id); + + // Assert + Assert.DoesNotContain(results, p => p.Id == policy.Id); + + // Annul + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteAsync(user); + } + + [Theory, DatabaseData] + public async Task DoesNotReturnPolicies_ForOtherUsers( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IPolicyRepository policyRepository) + { + // Arrange + var targetUser = await userRepository.CreateTestUserAsync(); + var otherUser = await userRepository.CreateTestUserAsync(); + + var organization = await organizationRepository.CreateTestOrganizationAsync(); + await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, otherUser); + var policy = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.TwoFactorAuthentication, + Enabled = true + }); + + // Act + var results = await policyRepository.GetManyConfirmedAcceptedByUserIdAsync(targetUser.Id); + + // Assert + Assert.DoesNotContain(results, p => p.Id == policy.Id); + + // Annul + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([targetUser, otherUser]); + } +} diff --git a/util/Migrator/DbScripts/2026-04-06_00_AddPolicy_ReadConfirmedAcceptedByUserId.sql b/util/Migrator/DbScripts/2026-04-06_00_AddPolicy_ReadConfirmedAcceptedByUserId.sql new file mode 100644 index 000000000000..f25561b7181a --- /dev/null +++ b/util/Migrator/DbScripts/2026-04-06_00_AddPolicy_ReadConfirmedAcceptedByUserId.sql @@ -0,0 +1,19 @@ +CREATE OR ALTER PROCEDURE [dbo].[Policy_ReadConfirmedAcceptedByUserId] + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + P.* + FROM + [dbo].[PolicyView] P + INNER JOIN + [dbo].[OrganizationUser] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN + [dbo].[Organization] O ON OU.[OrganizationId] = O.[Id] + WHERE + OU.[UserId] = @UserId + AND OU.[Status] IN (1, 2) -- 1 = Accepted, 2 = Confirmed +END +GO