diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d64612aba..3b96eeb468 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -314,7 +314,7 @@ jobs: output-format: sarif - name: Upload Grype results to GitHub - uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 + uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 with: sarif_file: ${{ steps.container-scan.outputs.sarif }} diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 156ebee165..1fa5c9587c 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -46,7 +46,7 @@ jobs: --output-path . ${{ env.INCREMENTAL }} - name: Upload Checkmarx results to GitHub - uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 + uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 with: sarif_file: cx_result.sarif @@ -85,6 +85,6 @@ jobs: /d:sonar.test.inclusions=test/,bitwarden_license/test/ \ /d:sonar.exclusions=test/,bitwarden_license/test/ \ /o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \ - /d:sonar.host.url="https://sonarcloud.io" + /d:sonar.host.url="https://sonarcloud.io" ${{ contains(github.event_name, 'pull_request') && format('/d:sonar.pullrequest.key={0}', github.event.pull_request.number) || '' }} dotnet build dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" diff --git a/bitwarden-server.sln b/bitwarden-server.sln index 75e7d7fade..e9aff53f8e 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -125,6 +125,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notifications.Test", "test\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test", "test\Infrastructure.Dapper.Test\Infrastructure.Dapper.Test.csproj", "{4A725DB3-BE4F-4C23-9087-82D0610D67AF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -313,6 +315,10 @@ Global {4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Debug|Any CPU.Build.0 = Debug|Any CPU {4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.ActiveCfg = Release|Any CPU {4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.Build.0 = Release|Any CPU + {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -363,6 +369,7 @@ Global {81673EFB-7134-4B4B-A32F-1EA05F0EF3CE} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} + {4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 2b834947af..abba8aff90 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -1,12 +1,15 @@ using System.Globalization; using Bit.Commercial.Core.Billing.Models; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models; using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Contracts; @@ -24,6 +27,7 @@ using Stripe; namespace Bit.Commercial.Core.Billing; public class ProviderBillingService( + IEventService eventService, IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, @@ -31,10 +35,93 @@ public class ProviderBillingService( IProviderInvoiceItemRepository providerInvoiceItemRepository, IProviderOrganizationRepository providerOrganizationRepository, IProviderPlanRepository providerPlanRepository, + IProviderUserRepository providerUserRepository, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, ITaxService taxService) : IProviderBillingService { + [RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)] + public async Task AddExistingOrganization( + Provider provider, + Organization organization, + string key) + { + await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, + new SubscriptionUpdateOptions + { + CancelAtPeriodEnd = false + }); + + var subscription = + await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId, + new SubscriptionCancelOptions + { + CancellationDetails = new SubscriptionCancellationDetailsOptions + { + Comment = $"Organization was added to Provider with ID {provider.Id}" + }, + InvoiceNow = true, + Prorate = true, + Expand = ["latest_invoice", "test_clock"] + }); + + var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; + + var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now; + + if (!wasTrialing && subscription.LatestInvoice.Status == StripeConstants.InvoiceStatus.Draft) + { + await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId, + new InvoiceFinalizeOptions { AutoAdvance = true }); + } + + var managedPlanType = await GetManagedPlanTypeAsync(provider, organization); + + // TODO: Replace with PricingClient + var plan = StaticStore.GetPlan(managedPlanType); + organization.Plan = plan.Name; + organization.PlanType = plan.Type; + organization.MaxCollections = plan.PasswordManager.MaxCollections; + organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb; + organization.UsePolicies = plan.HasPolicies; + organization.UseSso = plan.HasSso; + organization.UseGroups = plan.HasGroups; + organization.UseEvents = plan.HasEvents; + organization.UseDirectory = plan.HasDirectory; + organization.UseTotp = plan.HasTotp; + organization.Use2fa = plan.Has2fa; + organization.UseApi = plan.HasApi; + organization.UseResetPassword = plan.HasResetPassword; + organization.SelfHost = plan.HasSelfHost; + organization.UsersGetPremium = plan.UsersGetPremium; + organization.UseCustomPermissions = plan.HasCustomPermissions; + organization.UseScim = plan.HasScim; + organization.UseKeyConnector = plan.HasKeyConnector; + organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb; + organization.BillingEmail = provider.BillingEmail!; + organization.GatewaySubscriptionId = null; + organization.ExpirationDate = null; + organization.MaxAutoscaleSeats = null; + organization.Status = OrganizationStatusType.Managed; + + var providerOrganization = new ProviderOrganization + { + ProviderId = provider.Id, + OrganizationId = organization.Id, + Key = key + }; + + await Task.WhenAll( + organizationRepository.ReplaceAsync(organization), + providerOrganizationRepository.CreateAsync(providerOrganization), + ScaleSeats(provider, organization.PlanType, organization.Seats!.Value) + ); + + await eventService.LogProviderOrganizationEventAsync( + providerOrganization, + EventType.ProviderOrganization_Added); + } + public async Task ChangePlan(ChangeProviderPlanCommand command) { var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId); @@ -206,6 +293,81 @@ public class ProviderBillingService( return memoryStream.ToArray(); } + [RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)] + public async Task> GetAddableOrganizations( + Provider provider, + Guid userId) + { + var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, userId); + + if (providerUser is not { Status: ProviderUserStatusType.Confirmed }) + { + throw new UnauthorizedAccessException(); + } + + var candidates = await organizationRepository.GetAddableToProviderByUserIdAsync(userId, provider.Type); + + var active = (await Task.WhenAll(candidates.Select(async organization => + { + var subscription = await subscriberService.GetSubscription(organization); + return (organization, subscription); + }))) + .Where(pair => pair.subscription is + { + Status: + StripeConstants.SubscriptionStatus.Active or + StripeConstants.SubscriptionStatus.Trialing or + StripeConstants.SubscriptionStatus.PastDue + }).ToList(); + + if (active.Count == 0) + { + return []; + } + + return await Task.WhenAll(active.Select(async pair => + { + var (organization, _) = pair; + + var planName = DerivePlanName(provider, organization); + + var addable = new AddableOrganization( + organization.Id, + organization.Name, + planName, + organization.Seats!.Value); + + if (providerUser.Type != ProviderUserType.ServiceUser) + { + return addable; + } + + var applicablePlanType = await GetManagedPlanTypeAsync(provider, organization); + + var requiresPurchase = + await SeatAdjustmentResultsInPurchase(provider, applicablePlanType, organization.Seats!.Value); + + return addable with { Disabled = requiresPurchase }; + })); + + string DerivePlanName(Provider localProvider, Organization localOrganization) + { + if (localProvider.Type == ProviderType.Msp) + { + return localOrganization.PlanType switch + { + var planType when PlanConstants.EnterprisePlanTypes.Contains(planType) => "Enterprise", + var planType when PlanConstants.TeamsPlanTypes.Contains(planType) => "Teams", + _ => throw new BillingException() + }; + } + + // TODO: Replace with PricingClient + var plan = StaticStore.GetPlan(localOrganization.PlanType); + return plan.Name; + } + } + public async Task ScaleSeats( Provider provider, PlanType planType, @@ -582,4 +744,21 @@ public class ProviderBillingService( return providerPlan; } + + private async Task GetManagedPlanTypeAsync( + Provider provider, + Organization organization) + { + if (provider.Type == ProviderType.MultiOrganizationEnterprise) + { + return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType; + } + + return organization.PlanType switch + { + var planType when PlanConstants.TeamsPlanTypes.Contains(planType) => PlanType.TeamsMonthly, + var planType when PlanConstants.EnterprisePlanTypes.Contains(planType) => PlanType.EnterpriseMonthly, + _ => throw new BillingException() + }; + } } diff --git a/dev/.env.example b/dev/.env.example index d0ebf50efb..f0aed83a59 100644 --- a/dev/.env.example +++ b/dev/.env.example @@ -20,4 +20,8 @@ IDP_SP_ACS_URL=http://localhost:51822/saml2/yourOrgIdHere/Acs # Optional reverse proxy configuration # Should match server listen ports in reverse-proxy.conf API_PROXY_PORT=4100 -IDENTITY_PROXY_PORT=33756 \ No newline at end of file +IDENTITY_PROXY_PORT=33756 + +# Optional RabbitMQ configuration +RABBITMQ_DEFAULT_USER=bitwarden +RABBITMQ_DEFAULT_PASS=SET_A_PASSWORD_HERE_123 diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index c02d3c872b..d23eaefbb0 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -84,6 +84,20 @@ services: profiles: - idp + rabbitmq: + image: rabbitmq:management + container_name: rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS} + volumes: + - rabbitmq_data:/var/lib/rabbitmq_data + profiles: + - rabbitmq + reverse-proxy: image: nginx:alpine container_name: reverse-proxy @@ -99,3 +113,4 @@ volumes: mssql_dev_data: postgres_dev_data: mysql_dev_data: + rabbitmq_data: diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 3fdef169b4..60a5a39612 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -421,6 +421,11 @@ public class OrganizationsController : Controller private void UpdateOrganization(Organization organization, OrganizationEditModel model) { + if (_accessControlService.UserHasPermission(Permission.Org_Name_Edit)) + { + organization.Name = WebUtility.HtmlEncode(model.Name); + } + if (_accessControlService.UserHasPermission(Permission.Org_CheckEnabledBox)) { organization.Enabled = model.Enabled; diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index cdc7608675..aeff65c900 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -12,6 +12,7 @@ var canViewBilling = AccessControlService.UserHasPermission(Permission.Org_Billing_View); var canViewPlan = AccessControlService.UserHasPermission(Permission.Org_Plan_View); var canViewLicensing = AccessControlService.UserHasPermission(Permission.Org_Licensing_View); + var canEditName = AccessControlService.UserHasPermission(Permission.Org_Name_Edit); var canCheckEnabled = AccessControlService.UserHasPermission(Permission.Org_CheckEnabledBox); var canEditPlan = AccessControlService.UserHasPermission(Permission.Org_Plan_Edit); var canEditLicensing = AccessControlService.UserHasPermission(Permission.Org_Licensing_Edit); @@ -28,7 +29,7 @@
- +
diff --git a/src/Admin/Enums/Permissions.cs b/src/Admin/Enums/Permissions.cs index 20c500c061..4edcd742b4 100644 --- a/src/Admin/Enums/Permissions.cs +++ b/src/Admin/Enums/Permissions.cs @@ -22,6 +22,7 @@ public enum Permission Org_List_View, Org_OrgInformation_View, Org_GeneralDetails_View, + Org_Name_Edit, Org_CheckEnabledBox, Org_BusinessInformation_View, Org_InitiateTrial, diff --git a/src/Admin/Utilities/RolePermissionMapping.cs b/src/Admin/Utilities/RolePermissionMapping.cs index 4b5a4e3802..3b510781be 100644 --- a/src/Admin/Utilities/RolePermissionMapping.cs +++ b/src/Admin/Utilities/RolePermissionMapping.cs @@ -24,6 +24,7 @@ public static class RolePermissionMapping Permission.User_Billing_Edit, Permission.User_Billing_LaunchGateway, Permission.User_NewDeviceException_Edit, + Permission.Org_Name_Edit, Permission.Org_CheckEnabledBox, Permission.Org_List_View, Permission.Org_OrgInformation_View, @@ -71,6 +72,7 @@ public static class RolePermissionMapping Permission.User_Billing_Edit, Permission.User_Billing_LaunchGateway, Permission.User_NewDeviceException_Edit, + Permission.Org_Name_Edit, Permission.Org_CheckEnabledBox, Permission.Org_List_View, Permission.Org_OrgInformation_View, @@ -116,6 +118,7 @@ public static class RolePermissionMapping Permission.User_Billing_View, Permission.User_Billing_LaunchGateway, Permission.User_NewDeviceException_Edit, + Permission.Org_Name_Edit, Permission.Org_CheckEnabledBox, Permission.Org_List_View, Permission.Org_OrgInformation_View, @@ -148,6 +151,7 @@ public static class RolePermissionMapping Permission.User_Billing_View, Permission.User_Billing_Edit, Permission.User_Billing_LaunchGateway, + Permission.Org_Name_Edit, Permission.Org_CheckEnabledBox, Permission.Org_List_View, Permission.Org_OrgInformation_View, @@ -185,6 +189,7 @@ public static class RolePermissionMapping Permission.User_Premium_View, Permission.User_Licensing_View, Permission.User_Licensing_Edit, + Permission.Org_Name_Edit, Permission.Org_CheckEnabledBox, Permission.Org_List_View, Permission.Org_OrgInformation_View, diff --git a/src/Api/Billing/Controllers/ProviderClientsController.cs b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs similarity index 67% rename from src/Api/Billing/Controllers/ProviderClientsController.cs rename to src/Api/AdminConsole/Controllers/ProviderClientsController.cs index 0c09fa7baf..38d8b254d7 100644 --- a/src/Api/Billing/Controllers/ProviderClientsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs @@ -1,4 +1,6 @@ -using Bit.Api.Billing.Models.Requests; +using Bit.Api.Billing.Controllers; +using Bit.Api.Billing.Models.Requests; +using Bit.Core; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Services; @@ -7,13 +9,15 @@ using Bit.Core.Enums; using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Billing.Controllers; +namespace Bit.Api.AdminConsole.Controllers; [Route("providers/{providerId:guid}/clients")] public class ProviderClientsController( ICurrentContext currentContext, + IFeatureService featureService, ILogger logger, IOrganizationRepository organizationRepository, IProviderBillingService providerBillingService, @@ -22,7 +26,10 @@ public class ProviderClientsController( IProviderService providerService, IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService) { + private readonly ICurrentContext _currentContext = currentContext; + [HttpPost] + [SelfHosted(NotSelfHostedOnly = true)] public async Task CreateAsync( [FromRoute] Guid providerId, [FromBody] CreateClientOrganizationRequestBody requestBody) @@ -80,6 +87,7 @@ public class ProviderClientsController( } [HttpPut("{providerOrganizationId:guid}")] + [SelfHosted(NotSelfHostedOnly = true)] public async Task UpdateAsync( [FromRoute] Guid providerId, [FromRoute] Guid providerOrganizationId, @@ -113,7 +121,7 @@ public class ProviderClientsController( clientOrganization.PlanType, seatAdjustment); - if (seatAdjustmentResultsInPurchase && !currentContext.ProviderProviderAdmin(provider.Id)) + if (seatAdjustmentResultsInPurchase && !_currentContext.ProviderProviderAdmin(provider.Id)) { return Error.Unauthorized("Service users cannot purchase additional seats."); } @@ -127,4 +135,58 @@ public class ProviderClientsController( return TypedResults.Ok(); } + + [HttpGet("addable")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task GetAddableOrganizationsAsync([FromRoute] Guid providerId) + { + if (!featureService.IsEnabled(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)) + { + return Error.NotFound(); + } + + var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId); + + if (provider == null) + { + return result; + } + + var userId = _currentContext.UserId; + + if (!userId.HasValue) + { + return Error.Unauthorized(); + } + + var addable = + await providerBillingService.GetAddableOrganizations(provider, userId.Value); + + return TypedResults.Ok(addable); + } + + [HttpPost("existing")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task AddExistingOrganizationAsync( + [FromRoute] Guid providerId, + [FromBody] AddExistingOrganizationRequestBody requestBody) + { + var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId); + + if (provider == null) + { + return result; + } + + var organization = await organizationRepository.GetByIdAsync(requestBody.OrganizationId); + + if (organization == null) + { + return Error.BadRequest("The organization being added to the provider does not exist."); + } + + await providerBillingService.AddExistingOrganization(provider, organization, requestBody.Key); + + return TypedResults.Ok(); + } } diff --git a/src/Api/AdminConsole/Public/Controllers/EventsController.cs b/src/Api/AdminConsole/Public/Controllers/EventsController.cs index d2e198de17..992b7453aa 100644 --- a/src/Api/AdminConsole/Public/Controllers/EventsController.cs +++ b/src/Api/AdminConsole/Public/Controllers/EventsController.cs @@ -36,7 +36,7 @@ public class EventsController : Controller /// If no filters are provided, it will return the last 30 days of event for the organization. /// [HttpGet] - [ProducesResponseType(typeof(ListResponseModel), (int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(PagedListResponseModel), (int)HttpStatusCode.OK)] public async Task List([FromQuery] EventFilterRequestModel request) { var dateRange = request.ToDateRange(); @@ -65,7 +65,7 @@ public class EventsController : Controller } var eventResponses = result.Data.Select(e => new EventResponseModel(e)); - var response = new ListResponseModel(eventResponses, result.ContinuationToken); + var response = new PagedListResponseModel(eventResponses, result.ContinuationToken); return new JsonResult(response); } } diff --git a/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs b/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs new file mode 100644 index 0000000000..c2add17793 --- /dev/null +++ b/src/Api/Billing/Models/Requests/AddExistingOrganizationRequestBody.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests; + +public class AddExistingOrganizationRequestBody +{ + [Required(ErrorMessage = "'key' must be provided")] + public string Key { get; set; } + + [Required(ErrorMessage = "'organizationId' must be provided")] + public Guid OrganizationId { get; set; } +} diff --git a/src/Api/Models/Public/Response/ListResponseModel.cs b/src/Api/Models/Public/Response/ListResponseModel.cs index 0865be3e8e..a55d6f62bb 100644 --- a/src/Api/Models/Public/Response/ListResponseModel.cs +++ b/src/Api/Models/Public/Response/ListResponseModel.cs @@ -4,10 +4,9 @@ namespace Bit.Api.Models.Public.Response; public class ListResponseModel : IResponseModel where T : IResponseModel { - public ListResponseModel(IEnumerable data, string continuationToken = null) + public ListResponseModel(IEnumerable data) { Data = data; - ContinuationToken = continuationToken; } /// @@ -21,8 +20,4 @@ public class ListResponseModel : IResponseModel where T : IResponseModel /// [Required] public IEnumerable Data { get; set; } - /// - /// A cursor for use in pagination. - /// - public string ContinuationToken { get; set; } } diff --git a/src/Api/Models/Public/Response/PagedListResponseModel.cs b/src/Api/Models/Public/Response/PagedListResponseModel.cs new file mode 100644 index 0000000000..b0f25cb4f8 --- /dev/null +++ b/src/Api/Models/Public/Response/PagedListResponseModel.cs @@ -0,0 +1,10 @@ +namespace Bit.Api.Models.Public.Response; + +public class PagedListResponseModel(IEnumerable data, string continuationToken) : ListResponseModel(data) + where T : IResponseModel +{ + /// + /// A cursor for use in pagination. + /// + public string ContinuationToken { get; set; } = continuationToken; +} diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs index 5b274d3f88..584d95ffe2 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Models.Data.Organizations; #nullable enable @@ -22,4 +23,5 @@ public interface IOrganizationRepository : IRepository /// Gets the organizations that have a verified domain matching the user's email domain. /// Task> GetByVerifiedUserEmailDomainAsync(Guid userId); + Task> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType); } diff --git a/src/Core/AdminConsole/Services/IEventMessageHandler.cs b/src/Core/AdminConsole/Services/IEventMessageHandler.cs new file mode 100644 index 0000000000..5df9544c29 --- /dev/null +++ b/src/Core/AdminConsole/Services/IEventMessageHandler.cs @@ -0,0 +1,8 @@ +using Bit.Core.Models.Data; + +namespace Bit.Core.Services; + +public interface IEventMessageHandler +{ + Task HandleEventAsync(EventMessage eventMessage); +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs new file mode 100644 index 0000000000..6e4158122c --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventRepositoryHandler.cs @@ -0,0 +1,14 @@ +using Bit.Core.Models.Data; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Services; + +public class EventRepositoryHandler( + [FromKeyedServices("persistent")] IEventWriteService eventWriteService) + : IEventMessageHandler +{ + public Task HandleEventAsync(EventMessage eventMessage) + { + return eventWriteService.CreateAsync(eventMessage); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/HttpPostEventHandler.cs b/src/Core/AdminConsole/Services/Implementations/HttpPostEventHandler.cs new file mode 100644 index 0000000000..8aece0c1da --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/HttpPostEventHandler.cs @@ -0,0 +1,28 @@ +using System.Net.Http.Json; +using Bit.Core.Models.Data; +using Bit.Core.Settings; + +namespace Bit.Core.Services; + +public class HttpPostEventHandler : IEventMessageHandler +{ + private readonly HttpClient _httpClient; + private readonly string _httpPostUrl; + + public const string HttpClientName = "HttpPostEventHandlerHttpClient"; + + public HttpPostEventHandler( + IHttpClientFactory httpClientFactory, + GlobalSettings globalSettings) + { + _httpClient = httpClientFactory.CreateClient(HttpClientName); + _httpPostUrl = globalSettings.EventLogging.RabbitMq.HttpPostUrl; + } + + public async Task HandleEventAsync(EventMessage eventMessage) + { + var content = JsonContent.Create(eventMessage); + var response = await _httpClient.PostAsync(_httpPostUrl, content); + response.EnsureSuccessStatusCode(); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs new file mode 100644 index 0000000000..d89cf890ac --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/RabbitMqEventWriteService.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using Bit.Core.Models.Data; +using Bit.Core.Settings; +using RabbitMQ.Client; + +namespace Bit.Core.Services; +public class RabbitMqEventWriteService : IEventWriteService, IAsyncDisposable +{ + private readonly ConnectionFactory _factory; + private readonly Lazy> _lazyConnection; + private readonly string _exchangeName; + + public RabbitMqEventWriteService(GlobalSettings globalSettings) + { + _factory = new ConnectionFactory + { + HostName = globalSettings.EventLogging.RabbitMq.HostName, + UserName = globalSettings.EventLogging.RabbitMq.Username, + Password = globalSettings.EventLogging.RabbitMq.Password + }; + _exchangeName = globalSettings.EventLogging.RabbitMq.ExchangeName; + + _lazyConnection = new Lazy>(CreateConnectionAsync); + } + + public async Task CreateAsync(IEvent e) + { + var connection = await _lazyConnection.Value; + using var channel = await connection.CreateChannelAsync(); + + await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true); + + var body = JsonSerializer.SerializeToUtf8Bytes(e); + + await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: string.Empty, body: body); + } + + public async Task CreateManyAsync(IEnumerable events) + { + var connection = await _lazyConnection.Value; + using var channel = await connection.CreateChannelAsync(); + await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true); + + foreach (var e in events) + { + var body = JsonSerializer.SerializeToUtf8Bytes(e); + + await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: string.Empty, body: body); + } + } + + public async ValueTask DisposeAsync() + { + if (_lazyConnection.IsValueCreated) + { + var connection = await _lazyConnection.Value; + await connection.DisposeAsync(); + } + } + + private async Task CreateConnectionAsync() + { + return await _factory.CreateConnectionAsync(); + } +} diff --git a/src/Core/Billing/Constants/PlanConstants.cs b/src/Core/Billing/Constants/PlanConstants.cs new file mode 100644 index 0000000000..1ac5b8e750 --- /dev/null +++ b/src/Core/Billing/Constants/PlanConstants.cs @@ -0,0 +1,30 @@ +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Constants; + +public static class PlanConstants +{ + public static List EnterprisePlanTypes => + [ + PlanType.EnterpriseAnnually2019, + PlanType.EnterpriseAnnually2020, + PlanType.EnterpriseAnnually2023, + PlanType.EnterpriseAnnually, + PlanType.EnterpriseMonthly2019, + PlanType.EnterpriseMonthly2020, + PlanType.EnterpriseMonthly2023, + PlanType.EnterpriseMonthly + ]; + + public static List TeamsPlanTypes => + [ + PlanType.TeamsAnnually2019, + PlanType.TeamsAnnually2020, + PlanType.TeamsAnnually2023, + PlanType.TeamsAnnually, + PlanType.TeamsMonthly2019, + PlanType.TeamsMonthly2020, + PlanType.TeamsMonthly2023, + PlanType.TeamsMonthly + ]; +} diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 7371b8b7e9..e3c2b7245e 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -31,6 +31,16 @@ public static class StripeConstants public const string TaxIdInvalid = "tax_id_invalid"; } + public static class InvoiceStatus + { + public const string Draft = "draft"; + } + + public static class MetadataKeys + { + public const string OrganizationId = "organizationId"; + } + public static class PaymentBehavior { public const string DefaultIncomplete = "default_incomplete"; diff --git a/src/Core/Billing/Models/AddableOrganization.cs b/src/Core/Billing/Models/AddableOrganization.cs new file mode 100644 index 0000000000..fe6d5458bd --- /dev/null +++ b/src/Core/Billing/Models/AddableOrganization.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Billing.Models; + +public record AddableOrganization( + Guid Id, + string Name, + string Plan, + int Seats, + bool Disabled = false); diff --git a/src/Core/Billing/Services/IPremiumUserBillingService.cs b/src/Core/Billing/Services/IPremiumUserBillingService.cs index f74bf6c8da..2161b247b9 100644 --- a/src/Core/Billing/Services/IPremiumUserBillingService.cs +++ b/src/Core/Billing/Services/IPremiumUserBillingService.cs @@ -1,4 +1,5 @@ -using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Models; +using Bit.Core.Billing.Models.Sales; using Bit.Core.Entities; namespace Bit.Core.Billing.Services; @@ -27,4 +28,9 @@ public interface IPremiumUserBillingService /// /// Task Finalize(PremiumUserSale sale); + + Task UpdatePaymentMethod( + User user, + TokenizedPaymentSource tokenizedPaymentSource, + TaxInformation taxInformation); } diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Services/IProviderBillingService.cs index 20e7407628..d6983da03e 100644 --- a/src/Core/Billing/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Services/IProviderBillingService.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models; using Bit.Core.Billing.Services.Contracts; using Bit.Core.Models.Business; using Stripe; @@ -10,6 +11,11 @@ namespace Bit.Core.Billing.Services; public interface IProviderBillingService { + Task AddExistingOrganization( + Provider provider, + Organization organization, + string key); + /// /// Changes the assigned provider plan for the provider. /// @@ -35,6 +41,10 @@ public interface IProviderBillingService Task GenerateClientInvoiceReport( string invoiceId); + Task> GetAddableOrganizations( + Provider provider, + Guid userId); + /// /// Scales the 's seats for the specified using the provided . /// This operation may autoscale the provider's Stripe depending on the 's seat minimum for the diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index 201de22525..1bc4f792d7 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -356,11 +356,20 @@ public class OrganizationBillingService( } } + var customerHasTaxInfo = customer is + { + Address: + { + Country: not null and not "", + PostalCode: not null and not "" + } + }; + var subscriptionCreateOptions = new SubscriptionCreateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { - Enabled = true + Enabled = customerHasTaxInfo }, CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, Customer = customer.Id, diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 0672a8d5e7..ed841c9576 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -1,5 +1,6 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Entities; using Bit.Core.Enums; @@ -58,6 +59,28 @@ public class PremiumUserBillingService( await userRepository.ReplaceAsync(user); } + public async Task UpdatePaymentMethod( + User user, + TokenizedPaymentSource tokenizedPaymentSource, + TaxInformation taxInformation) + { + if (string.IsNullOrEmpty(user.GatewayCustomerId)) + { + var customer = await CreateCustomerAsync(user, + new CustomerSetup { TokenizedPaymentSource = tokenizedPaymentSource, TaxInformation = taxInformation }); + + user.Gateway = GatewayType.Stripe; + user.GatewayCustomerId = customer.Id; + + await userRepository.ReplaceAsync(user); + } + else + { + await subscriberService.UpdatePaymentSource(user, tokenizedPaymentSource); + await subscriberService.UpdateTaxInformation(user, taxInformation); + } + } + private async Task CreateCustomerAsync( User user, CustomerSetup customerSetup) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 6d70f0b3ce..6f9960919a 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -107,10 +107,17 @@ public static class FeatureFlagKeys public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; public const string IntegrationPage = "pm-14505-admin-console-integration-page"; public const string DeviceApprovalRequestAdminNotifications = "pm-15637-device-approval-request-admin-notifications"; + public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission"; + + /* Tools Team */ + public const string ItemShare = "item-share"; + public const string GeneratorToolsModernization = "generator-tools-modernization"; + public const string MemberAccessReport = "ac-2059-member-access-report"; + public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; + public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection"; - public const string ItemShare = "item-share"; public const string DuoRedirect = "duo-redirect"; public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section"; @@ -120,7 +127,6 @@ public static class FeatureFlagKeys public const string RestrictProviderAccess = "restrict-provider-access"; public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service"; public const string VaultBulkManagementAction = "vault-bulk-management-action"; - public const string MemberAccessReport = "ac-2059-member-access-report"; public const string InlineMenuFieldQualification = "inline-menu-field-qualification"; public const string TwoFactorComponentRefactor = "two-factor-component-refactor"; public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements"; @@ -145,9 +151,7 @@ public static class FeatureFlagKeys public const string TrialPayment = "PM-8163-trial-payment"; public const string RemoveServerVersionHeader = "remove-server-version-header"; public const string PM12275_MultiOrganizationEnterprises = "pm-12275-multi-organization-enterprises"; - public const string GeneratorToolsModernization = "generator-tools-modernization"; public const string NewDeviceVerification = "new-device-verification"; - public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application"; public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"; public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; public const string SecurityTasks = "security-tasks"; @@ -169,7 +173,8 @@ public static class FeatureFlagKeys public const string AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner"; public const string SingleTapPasskeyCreation = "single-tap-passkey-creation"; public const string SingleTapPasskeyAuthentication = "single-tap-passkey-authentication"; - public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; + public const string EnablePMAuthenticatorSync = "enable-pm-bwa-sync"; + public const string P15179_AddExistingOrgsFromProviderPortal = "PM-15179-add-existing-orgs-from-provider-portal"; public static List GetAllKeys() { diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 7a5f7e2543..7b319e56c9 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + @@ -40,7 +40,7 @@ - + @@ -70,12 +70,13 @@ + - + diff --git a/src/Core/Services/EventLoggingListenerService.cs b/src/Core/Services/EventLoggingListenerService.cs new file mode 100644 index 0000000000..60b8789a6b --- /dev/null +++ b/src/Core/Services/EventLoggingListenerService.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Hosting; + +namespace Bit.Core.Services; + +public abstract class EventLoggingListenerService : BackgroundService +{ + protected readonly IEventMessageHandler _handler; + + protected EventLoggingListenerService(IEventMessageHandler handler) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + } +} diff --git a/src/Core/Services/Implementations/RabbitMqEventListenerService.cs b/src/Core/Services/Implementations/RabbitMqEventListenerService.cs new file mode 100644 index 0000000000..9360170368 --- /dev/null +++ b/src/Core/Services/Implementations/RabbitMqEventListenerService.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using Bit.Core.Models.Data; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; + +namespace Bit.Core.Services; + +public class RabbitMqEventListenerService : EventLoggingListenerService +{ + private IChannel _channel; + private IConnection _connection; + private readonly string _exchangeName; + private readonly ConnectionFactory _factory; + private readonly ILogger _logger; + private readonly string _queueName; + + public RabbitMqEventListenerService( + IEventMessageHandler handler, + ILogger logger, + GlobalSettings globalSettings, + string queueName) : base(handler) + { + _factory = new ConnectionFactory + { + HostName = globalSettings.EventLogging.RabbitMq.HostName, + UserName = globalSettings.EventLogging.RabbitMq.Username, + Password = globalSettings.EventLogging.RabbitMq.Password + }; + _exchangeName = globalSettings.EventLogging.RabbitMq.ExchangeName; + _logger = logger; + _queueName = queueName; + } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + _connection = await _factory.CreateConnectionAsync(cancellationToken); + _channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken); + + await _channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true); + await _channel.QueueDeclareAsync(queue: _queueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: null, + cancellationToken: cancellationToken); + await _channel.QueueBindAsync(queue: _queueName, + exchange: _exchangeName, + routingKey: string.Empty, + cancellationToken: cancellationToken); + await base.StartAsync(cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var consumer = new AsyncEventingBasicConsumer(_channel); + consumer.ReceivedAsync += async (_, eventArgs) => + { + try + { + var eventMessage = JsonSerializer.Deserialize(eventArgs.Body.Span); + await _handler.HandleEventAsync(eventMessage); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while processing the message"); + } + }; + + await _channel.BasicConsumeAsync(_queueName, autoAck: true, consumer: consumer, cancellationToken: stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(1_000, stoppingToken); + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await _channel.CloseAsync(); + await _connection.CloseAsync(); + await base.StopAsync(cancellationToken); + } + + public override void Dispose() + { + _channel.Dispose(); + _connection.Dispose(); + base.Dispose(); + } +} diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 157bfd3a6e..11d4042def 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -1044,11 +1045,11 @@ public class UserService : UserManager, IUserService, IDisposable throw new BadRequestException("Invalid token."); } - var updated = await _paymentService.UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken, taxInfo: taxInfo); - if (updated) - { - await SaveUserAsync(user); - } + var tokenizedPaymentSource = new TokenizedPaymentSource(paymentMethodType, paymentToken); + var taxInformation = TaxInformation.From(taxInfo); + + await _premiumUserBillingService.UpdatePaymentMethod(user, tokenizedPaymentSource, taxInformation); + await SaveUserAsync(user); } public async Task CancelPremiumAsync(User user, bool? endOfPeriod = null) diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 97d66aed53..d039102eb9 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -53,6 +53,7 @@ public class GlobalSettings : IGlobalSettings public virtual SqlSettings PostgreSql { get; set; } = new SqlSettings(); public virtual SqlSettings MySql { get; set; } = new SqlSettings(); public virtual SqlSettings Sqlite { get; set; } = new SqlSettings() { ConnectionString = "Data Source=:memory:" }; + public virtual EventLoggingSettings EventLogging { get; set; } = new EventLoggingSettings(); public virtual MailSettings Mail { get; set; } = new MailSettings(); public virtual IConnectionStringSettings Storage { get; set; } = new ConnectionStringSettings(); public virtual ConnectionStringSettings Events { get; set; } = new ConnectionStringSettings(); @@ -256,6 +257,44 @@ public class GlobalSettings : IGlobalSettings } } + public class EventLoggingSettings + { + public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings(); + + public class RabbitMqSettings + { + private string _hostName; + private string _username; + private string _password; + private string _exchangeName; + + public virtual string EventRepositoryQueueName { get; set; } = "events-write-queue"; + public virtual string HttpPostQueueName { get; set; } = "events-httpPost-queue"; + public virtual string HttpPostUrl { get; set; } + + public string HostName + { + get => _hostName; + set => _hostName = value.Trim('"'); + } + public string Username + { + get => _username; + set => _username = value.Trim('"'); + } + public string Password + { + get => _password; + set => _password = value.Trim('"'); + } + public string ExchangeName + { + get => _exchangeName; + set => _exchangeName = value.Trim('"'); + } + } + } + public class ConnectionStringSettings : IConnectionStringSettings { private string _connectionString; diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index afe35ed34b..b89df8abf5 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -27,4 +27,5 @@ public interface IGlobalSettings string DatabaseProvider { get; set; } GlobalSettings.SqlSettings SqlServer { get; set; } string DevelopmentDirectory { get; set; } + GlobalSettings.EventLoggingSettings EventLogging { get; set; } } diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index bac39c68dd..b692733a55 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -82,6 +82,35 @@ public class Startup { services.AddHostedService(); } + + // Optional RabbitMQ Listeners + if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) && + CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) && + CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) && + CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName)) + { + services.AddSingleton(); + services.AddKeyedSingleton("persistent"); + services.AddSingleton(provider => + new RabbitMqEventListenerService( + provider.GetRequiredService(), + provider.GetRequiredService>(), + provider.GetRequiredService(), + globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName)); + + if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HttpPostUrl)) + { + services.AddSingleton(); + services.AddHttpClient(HttpPostEventHandler.HttpClientName); + + services.AddSingleton(provider => + new RabbitMqEventListenerService( + provider.GetRequiredService(), + provider.GetRequiredService>(), + provider.GetRequiredService(), + globalSettings.EventLogging.RabbitMq.HttpPostQueueName)); + } + } } public void Configure( diff --git a/src/Identity/Billing/Controller/AccountsController.cs b/src/Identity/Billing/Controller/AccountsController.cs index aada40bcb2..96ec1280cd 100644 --- a/src/Identity/Billing/Controller/AccountsController.cs +++ b/src/Identity/Billing/Controller/AccountsController.cs @@ -4,6 +4,7 @@ using Bit.Core.Context; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; +using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.Mvc; @@ -17,6 +18,7 @@ public class AccountsController( IReferenceEventService referenceEventService) : Microsoft.AspNetCore.Mvc.Controller { [HttpPost("trial/send-verification-email")] + [SelfHosted(NotSelfHostedOnly = true)] public async Task PostTrialInitiationSendVerificationEmailAsync([FromBody] TrialSendVerificationEmailRequestModel model) { var token = await sendTrialInitiationEmailForRegistrationCommand.Handle( diff --git a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs index 1b148c5974..fee10e10ff 100644 --- a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs @@ -85,28 +85,17 @@ public class DeviceValidator( } } - // At this point we have established either new device verification is not required or the NewDeviceOtp is valid + // At this point we have established either new device verification is not required or the NewDeviceOtp is valid, + // so we save the device to the database and proceed with authentication requestDevice.UserId = context.User.Id; await _deviceService.SaveAsync(requestDevice); context.Device = requestDevice; - // backwards compatibility -- If NewDeviceVerification not enabled send the new login emails - // PM-13340: removal Task; remove entire if block emails should no longer be sent - if (!_featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification)) + if (!_globalSettings.DisableEmailNewDevice) { - // This ensures the user doesn't receive a "new device" email on the first login - var now = DateTime.UtcNow; - if (now - context.User.CreationDate > TimeSpan.FromMinutes(10)) - { - var deviceType = requestDevice.Type.GetType().GetMember(requestDevice.Type.ToString()) - .FirstOrDefault()?.GetCustomAttribute()?.GetName(); - if (!_globalSettings.DisableEmailNewDevice) - { - await _mailService.SendNewDeviceLoggedInEmail(context.User.Email, deviceType, now, - _currentContext.IpAddress); - } - } + await SendNewDeviceLoginEmail(context.User, requestDevice); } + return true; } @@ -174,6 +163,19 @@ public class DeviceValidator( return DeviceValidationResultType.NewDeviceVerificationRequired; } + private async Task SendNewDeviceLoginEmail(User user, Device requestDevice) + { + // Ensure that the user doesn't receive a "new device" email on the first login + var now = DateTime.UtcNow; + if (now - user.CreationDate > TimeSpan.FromMinutes(10)) + { + var deviceType = requestDevice.Type.GetType().GetMember(requestDevice.Type.ToString()) + .FirstOrDefault()?.GetCustomAttribute()?.GetName(); + await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now, + _currentContext.IpAddress); + } + } + public async Task GetKnownDeviceAsync(User user, Device device) { if (user == null || device == null) diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs index 20fdf83155..f624f7da28 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs @@ -1,5 +1,6 @@ using System.Data; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Auth.Entities; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; @@ -180,4 +181,19 @@ public class OrganizationRepository : Repository, IOrganizat return result.ToList(); } } + + public async Task> GetAddableToProviderByUserIdAsync( + Guid userId, + ProviderType providerType) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var result = await connection.QueryAsync( + $"[{Schema}].[{Table}_ReadAddableToProviderByUserId]", + new { UserId = userId, ProviderType = providerType }, + commandType: CommandType.StoredProcedure); + + return result.ToList(); + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index c1c78eee60..ea4e1334c6 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -1,9 +1,12 @@ using AutoMapper; using AutoMapper.QueryableExtensions; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations; using Bit.Core.Repositories; +using LinqToDB.Tools; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -114,13 +117,19 @@ public class OrganizationRepository : Repository + { + PlanType.Free, + PlanType.Custom, + PlanType.FamiliesAnnually2019, + PlanType.FamiliesAnnually + }; + var query = from o in dbContext.Organizations - where - ((o.PlanType >= PlanType.TeamsMonthly2019 && o.PlanType <= PlanType.EnterpriseAnnually2019) || - (o.PlanType >= PlanType.TeamsMonthly2020 && o.PlanType <= PlanType.EnterpriseAnnually)) && - !dbContext.ProviderOrganizations.Any(po => po.OrganizationId == o.Id) && - (string.IsNullOrWhiteSpace(name) || EF.Functions.Like(o.Name, $"%{name}%")) + where o.PlanType.NotIn(disallowedPlanTypes) && + !dbContext.ProviderOrganizations.Any(po => po.OrganizationId == o.Id) && + (string.IsNullOrWhiteSpace(name) || EF.Functions.Like(o.Name, $"%{name}%")) select o; if (string.IsNullOrWhiteSpace(ownerEmail)) @@ -152,7 +161,7 @@ public class OrganizationRepository : Repository o.CreationDate).Skip(skip).Take(take).ToArrayAsync(); + return await query.OrderByDescending(o => o.CreationDate).ThenByDescending(o => o.Id).Skip(skip).Take(take).ToArrayAsync(); } public async Task UpdateStorageAsync(Guid id) @@ -298,6 +307,41 @@ public class OrganizationRepository : Repository> GetAddableToProviderByUserIdAsync( + Guid userId, + ProviderType providerType) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var planTypes = providerType switch + { + ProviderType.Msp => PlanConstants.EnterprisePlanTypes.Concat(PlanConstants.TeamsPlanTypes), + ProviderType.MultiOrganizationEnterprise => PlanConstants.EnterprisePlanTypes, + _ => [] + }; + + var query = + from organizationUser in dbContext.OrganizationUsers + join organization in dbContext.Organizations on organizationUser.OrganizationId equals organization.Id + where + organizationUser.UserId == userId && + organizationUser.Type == OrganizationUserType.Owner && + organizationUser.Status == OrganizationUserStatusType.Confirmed && + organization.Enabled && + organization.GatewayCustomerId != null && + organization.GatewaySubscriptionId != null && + organization.Seats > 0 && + organization.Status == OrganizationStatusType.Created && + !organization.UseSecretsManager && + organization.PlanType.In(planTypes) + select organization; + + return await query.ToArrayAsync(); + } + } + public Task EnableCollectionEnhancements(Guid organizationId) { throw new NotImplementedException("Collection enhancements migration is not yet supported for Entity Framework."); diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs index e339c13351..50d791b81b 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs @@ -46,27 +46,17 @@ public class OrganizationDomainRepository : Repository x.VerifiedDate == null - && x.JobRunCount != 3 - && x.NextRunDate.Year == date.Year - && x.NextRunDate.Month == date.Month - && x.NextRunDate.Day == date.Day - && x.NextRunDate.Hour == date.Hour) - .AsNoTracking() + var start36HoursWindow = date.AddHours(-36); + var end36HoursWindow = date; + + var pastDomains = await dbContext.OrganizationDomains + .Where(x => x.NextRunDate >= start36HoursWindow + && x.NextRunDate <= end36HoursWindow + && x.VerifiedDate == null + && x.JobRunCount != 3) .ToListAsync(); - //Get records that have ignored/failed by the background service - var pastDomains = dbContext.OrganizationDomains - .AsEnumerable() - .Where(x => (date - x.NextRunDate).TotalHours > 36 - && x.VerifiedDate == null - && x.JobRunCount != 3) - .ToList(); - - var results = domains.Union(pastDomains); - - return Mapper.Map>(results); + return Mapper.Map>(pastDomains); } public async Task GetOrganizationDomainSsoDetailsAsync(string email) diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index e1369d5366..622b3d7f39 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -325,7 +325,17 @@ public static class ServiceCollectionExtensions } else if (globalSettings.SelfHosted) { - services.AddSingleton(); + if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) && + CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) && + CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) && + CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName)) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } } else { diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadAddableToProviderByUserId.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadAddableToProviderByUserId.sql new file mode 100644 index 0000000000..e11109ae10 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/Organization_ReadAddableToProviderByUserId.sql @@ -0,0 +1,23 @@ +CREATE PROCEDURE [dbo].[Organization_ReadAddableToProviderByUserId] + @UserId UNIQUEIDENTIFIER, + @ProviderType TINYINT +AS +BEGIN + SET NOCOUNT ON + SELECT O.* FROM [dbo].[OrganizationUser] AS OU + JOIN [dbo].[Organization] AS O ON O.[Id] = OU.[OrganizationId] + WHERE + OU.[UserId] = @UserId AND + OU.[Type] = 0 AND + OU.[Status] = 2 AND + O.[Enabled] = 1 AND + O.[GatewayCustomerId] IS NOT NULL AND + O.[GatewaySubscriptionId] IS NOT NULL AND + O.[Seats] > 0 AND + O.[Status] = 1 AND + O.[UseSecretsManager] = 0 AND + -- All Teams & Enterprise for MSP + (@ProviderType = 0 AND O.[PlanType] IN (2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20) OR + -- All Enterprise for MOE + @ProviderType = 2 AND O.[PlanType] IN (4, 5, 10, 11, 14, 15, 19, 20)); +END diff --git a/src/Sql/dbo/Stored Procedures/Organization_UnassignedToProviderSearch.sql b/src/Sql/dbo/Stored Procedures/Organization_UnassignedToProviderSearch.sql index e40f78fee0..4f2269b583 100644 --- a/src/Sql/dbo/Stored Procedures/Organization_UnassignedToProviderSearch.sql +++ b/src/Sql/dbo/Stored Procedures/Organization_UnassignedToProviderSearch.sql @@ -1,5 +1,5 @@ CREATE PROCEDURE [dbo].[Organization_UnassignedToProviderSearch] - @Name NVARCHAR(50), + @Name NVARCHAR(55), @OwnerEmail NVARCHAR(256), @Skip INT = 0, @Take INT = 25 @@ -9,7 +9,7 @@ BEGIN SET NOCOUNT ON DECLARE @NameLikeSearch NVARCHAR(55) = '%' + @Name + '%' DECLARE @OwnerLikeSearch NVARCHAR(55) = @OwnerEmail + '%' - + IF @OwnerEmail IS NOT NULL BEGIN SELECT @@ -21,11 +21,11 @@ BEGIN INNER JOIN [dbo].[User] U ON U.[Id] = OU.[UserId] WHERE - ((O.[PlanType] >= 2 AND O.[PlanType] <= 5) OR (O.[PlanType] >= 8 AND O.[PlanType] <= 20) AND (O.PlanType <> 16)) -- All 'Teams' and 'Enterprise' organizations + O.[PlanType] NOT IN (0, 1, 6, 7) -- Not 'Free', 'Custom' or 'Families' AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id]) AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch) AND (U.[Email] LIKE @OwnerLikeSearch) - ORDER BY O.[CreationDate] DESC + ORDER BY O.[CreationDate] DESC, O.[Id] OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY END @@ -36,11 +36,11 @@ BEGIN FROM [dbo].[OrganizationView] O WHERE - ((O.[PlanType] >= 2 AND O.[PlanType] <= 5) OR (O.[PlanType] >= 8 AND O.[PlanType] <= 20) AND (O.PlanType <> 16)) -- All 'Teams' and 'Enterprise' organizations + O.[PlanType] NOT IN (0, 1, 6, 7) -- Not 'Free', 'Custom' or 'Families' AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id]) AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch) - ORDER BY O.[CreationDate] DESC + ORDER BY O.[CreationDate] DESC, O.[Id] OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY END -END \ No newline at end of file +END diff --git a/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs similarity index 98% rename from test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs rename to test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs index 86bacd9aa3..8ddd92a5fa 100644 --- a/test/Api.Test/Billing/Controllers/ProviderClientsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs @@ -1,5 +1,5 @@ using System.Security.Claims; -using Bit.Api.Billing.Controllers; +using Bit.Api.AdminConsole.Controllers; using Bit.Api.Billing.Models.Requests; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; @@ -19,10 +19,9 @@ using Microsoft.AspNetCore.Http.HttpResults; using NSubstitute; using NSubstitute.ReturnsExtensions; using Xunit; - using static Bit.Api.Test.Billing.Utilities; -namespace Bit.Api.Test.Billing.Controllers; +namespace Bit.Api.Test.AdminConsole.Controllers; [ControllerCustomize(typeof(ProviderClientsController))] [SutProviderCustomize] diff --git a/test/Common/MockedHttpClient/MockedHttpMessageHandler.cs b/test/Common/MockedHttpClient/MockedHttpMessageHandler.cs index 1b1bd52a03..8a6c1dae97 100644 --- a/test/Common/MockedHttpClient/MockedHttpMessageHandler.cs +++ b/test/Common/MockedHttpClient/MockedHttpMessageHandler.cs @@ -8,6 +8,8 @@ public class MockedHttpMessageHandler : HttpMessageHandler { private readonly List _matchers = new(); + public List CapturedRequests { get; } = new List(); + /// /// The fallback handler to use when the request does not match any of the provided matchers. /// @@ -16,6 +18,7 @@ public class MockedHttpMessageHandler : HttpMessageHandler protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + CapturedRequests.Add(request); var matcher = _matchers.FirstOrDefault(x => x.Matches(request)); if (matcher == null) { diff --git a/test/Core.Test/AdminConsole/Services/EventRepositoryHandlerTests.cs b/test/Core.Test/AdminConsole/Services/EventRepositoryHandlerTests.cs new file mode 100644 index 0000000000..2b143f5cb8 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/EventRepositoryHandlerTests.cs @@ -0,0 +1,24 @@ +using Bit.Core.Models.Data; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class EventRepositoryHandlerTests +{ + [Theory, BitAutoData] + public async Task HandleEventAsync_WritesEventToIEventWriteService( + EventMessage eventMessage, + SutProvider sutProvider) + { + await sutProvider.Sut.HandleEventAsync(eventMessage); + await sutProvider.GetDependency().Received(1).CreateAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(eventMessage)) + ); + } +} diff --git a/test/Core.Test/AdminConsole/Services/HttpPostEventHandlerTests.cs b/test/Core.Test/AdminConsole/Services/HttpPostEventHandlerTests.cs new file mode 100644 index 0000000000..414b1c54be --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/HttpPostEventHandlerTests.cs @@ -0,0 +1,66 @@ +using System.Net; +using System.Net.Http.Json; +using Bit.Core.Models.Data; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using Bit.Test.Common.MockedHttpClient; +using NSubstitute; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class HttpPostEventHandlerTests +{ + private readonly MockedHttpMessageHandler _handler; + private HttpClient _httpClient; + + private const string _httpPostUrl = "http://localhost/test/event"; + + public HttpPostEventHandlerTests() + { + _handler = new MockedHttpMessageHandler(); + _handler.Fallback + .WithStatusCode(HttpStatusCode.OK) + .WithContent(new StringContent("testtest")); + _httpClient = _handler.ToHttpClient(); + } + + public SutProvider GetSutProvider() + { + var clientFactory = Substitute.For(); + clientFactory.CreateClient(HttpPostEventHandler.HttpClientName).Returns(_httpClient); + + var globalSettings = new GlobalSettings(); + globalSettings.EventLogging.RabbitMq.HttpPostUrl = _httpPostUrl; + + return new SutProvider() + .SetDependency(globalSettings) + .SetDependency(clientFactory) + .Create(); + } + + [Theory, BitAutoData] + public async Task HandleEventAsync_PostsEventsToUrl(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(); + var content = JsonContent.Create(eventMessage); + + await sutProvider.Sut.HandleEventAsync(eventMessage); + sutProvider.GetDependency().Received(1).CreateClient( + Arg.Is(AssertHelper.AssertPropertyEqual(HttpPostEventHandler.HttpClientName)) + ); + + Assert.Single(_handler.CapturedRequests); + var request = _handler.CapturedRequests[0]; + Assert.NotNull(request); + var returned = await request.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(_httpPostUrl, request.RequestUri.ToString()); + AssertHelper.AssertPropertyEqual(eventMessage, returned, new[] { "IdempotencyId" }); + } +} diff --git a/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs b/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs new file mode 100644 index 0000000000..7f86758144 --- /dev/null +++ b/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs @@ -0,0 +1,29 @@ +using System.Net.Http.Json; +using Bit.Core.Enums; +using Bit.Events.Models; + +namespace Bit.Events.IntegrationTest.Controllers; + +public class CollectControllerTests +{ + // This is a very simple test, and should be updated to assert more things, but for now + // it ensures that the events startup doesn't throw any errors with fairly basic configuration. + [Fact] + public async Task Post_Works() + { + var eventsApplicationFactory = new EventsApplicationFactory(); + var (accessToken, _) = await eventsApplicationFactory.LoginWithNewAccount(); + var client = eventsApplicationFactory.CreateAuthedClient(accessToken); + + var response = await client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.User_ClientExportedVault, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } +} diff --git a/test/Events.IntegrationTest/Events.IntegrationTest.csproj b/test/Events.IntegrationTest/Events.IntegrationTest.csproj new file mode 100644 index 0000000000..0b51185298 --- /dev/null +++ b/test/Events.IntegrationTest/Events.IntegrationTest.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/test/Events.IntegrationTest/EventsApplicationFactory.cs b/test/Events.IntegrationTest/EventsApplicationFactory.cs new file mode 100644 index 0000000000..3faf5e81bf --- /dev/null +++ b/test/Events.IntegrationTest/EventsApplicationFactory.cs @@ -0,0 +1,57 @@ +using Bit.Identity.Models.Request.Accounts; +using Bit.IntegrationTestCommon.Factories; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Events.IntegrationTest; + +public class EventsApplicationFactory : WebApplicationFactoryBase +{ + private readonly IdentityApplicationFactory _identityApplicationFactory; + private const string _connectionString = "DataSource=:memory:"; + + public EventsApplicationFactory() + { + SqliteConnection = new SqliteConnection(_connectionString); + SqliteConnection.Open(); + + _identityApplicationFactory = new IdentityApplicationFactory(); + _identityApplicationFactory.SqliteConnection = SqliteConnection; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + + builder.ConfigureTestServices(services => + { + services.Configure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.BackchannelHttpHandler = _identityApplicationFactory.Server.CreateHandler(); + }); + }); + } + + /// + /// Helper for registering and logging in to a new account + /// + public async Task<(string Token, string RefreshToken)> LoginWithNewAccount(string email = "integration-test@bitwarden.com", string masterPasswordHash = "master_password_hash") + { + await _identityApplicationFactory.RegisterAsync(new RegisterRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + }); + + return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + SqliteConnection!.Dispose(); + } +} diff --git a/test/Events.IntegrationTest/GlobalUsings.cs b/test/Events.IntegrationTest/GlobalUsings.cs new file mode 100644 index 0000000000..9df1d42179 --- /dev/null +++ b/test/Events.IntegrationTest/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs index fa3a117c55..6e6406f16b 100644 --- a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs @@ -227,7 +227,7 @@ public class DeviceValidatorTests } [Theory, BitAutoData] - public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_SendsEmail_ReturnsTrue( + public async void ValidateRequestDeviceAsync_ExistingUserNewDeviceLogin_SendNewDeviceLoginEmail_ReturnsTrue( CustomValidatorRequestContext context, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request) { @@ -237,8 +237,6 @@ public class DeviceValidatorTests _globalSettings.DisableEmailNewDevice = false; _deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id) .Returns(null as Device); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification) - .Returns(false); // set user creation to more than 10 minutes ago context.User.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(11); @@ -253,7 +251,7 @@ public class DeviceValidatorTests } [Theory, BitAutoData] - public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_NewUser_DoesNotSendEmail_ReturnsTrue( + public async void ValidateRequestDeviceAsync_NewUserNewDeviceLogin_DoesNotSendNewDeviceLoginEmail_ReturnsTrue( CustomValidatorRequestContext context, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request) { @@ -263,8 +261,6 @@ public class DeviceValidatorTests _globalSettings.DisableEmailNewDevice = false; _deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id) .Returns(null as Device); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification) - .Returns(false); // set user creation to less than 10 minutes ago context.User.CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(9); @@ -279,7 +275,7 @@ public class DeviceValidatorTests } [Theory, BitAutoData] - public async void ValidateRequestDeviceAsync_NewDeviceVerificationFeatureFlagFalse_DisableEmailTrue_DoesNotSendEmail_ReturnsTrue( + public async void ValidateRequestDeviceAsynce_DisableNewDeviceLoginEmailTrue_DoesNotSendNewDeviceEmail_ReturnsTrue( CustomValidatorRequestContext context, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request) { @@ -289,8 +285,6 @@ public class DeviceValidatorTests _globalSettings.DisableEmailNewDevice = true; _deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id) .Returns(null as Device); - _featureService.IsEnabled(FeatureFlagKeys.NewDeviceVerification) - .Returns(false); // Act var result = await _sut.ValidateRequestDeviceAsync(request, context); diff --git a/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj b/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj index 65041c3023..1ba67ba61e 100644 --- a/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj +++ b/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj @@ -11,7 +11,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationDomainRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationDomainRepositoryTests.cs index 8e0b502a47..ad92f40efc 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationDomainRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationDomainRepositoryTests.cs @@ -188,4 +188,122 @@ public class OrganizationDomainRepositoryTests var expectedDomain2 = domains.FirstOrDefault(domain => domain.DomainName == organizationDomain2.DomainName); Assert.Null(expectedDomain2); } + + [DatabaseTheory, DatabaseData] + public async Task GetManyByNextRunDateAsync_ShouldReturnUnverifiedDomains( + IOrganizationRepository organizationRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + // Arrange + var id = Guid.NewGuid(); + + var organization1 = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = $"test+{id}@example.com", + Plan = "Test", + PrivateKey = "privatekey", + + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization1.Id, + DomainName = $"domain2+{id}@example.com", + Txt = "btw+12345" + }; + + var within36HoursWindow = 1; + organizationDomain.SetNextRunDate(within36HoursWindow); + + await organizationDomainRepository.CreateAsync(organizationDomain); + + var date = organizationDomain.NextRunDate; + + // Act + var domains = await organizationDomainRepository.GetManyByNextRunDateAsync(date); + + // Assert + var expectedDomain = domains.FirstOrDefault(domain => domain.DomainName == organizationDomain.DomainName); + Assert.NotNull(expectedDomain); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManyByNextRunDateAsync_ShouldNotReturnUnverifiedDomains_WhenNextRunDateIsOutside36hoursWindow( + IOrganizationRepository organizationRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + // Arrange + var id = Guid.NewGuid(); + + var organization1 = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = $"test+{id}@example.com", + Plan = "Test", + PrivateKey = "privatekey", + + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization1.Id, + DomainName = $"domain2+{id}@example.com", + Txt = "btw+12345" + }; + + var outside36HoursWindow = 20; + organizationDomain.SetNextRunDate(outside36HoursWindow); + + await organizationDomainRepository.CreateAsync(organizationDomain); + + var date = DateTimeOffset.UtcNow.Date.AddDays(1); + + // Act + var domains = await organizationDomainRepository.GetManyByNextRunDateAsync(date); + + // Assert + var expectedDomain = domains.FirstOrDefault(domain => domain.DomainName == organizationDomain.DomainName); + Assert.Null(expectedDomain); + } + + [DatabaseTheory, DatabaseData] + public async Task GetManyByNextRunDateAsync_ShouldNotReturnVerifiedDomains( + IOrganizationRepository organizationRepository, + IOrganizationDomainRepository organizationDomainRepository) + { + // Arrange + var id = Guid.NewGuid(); + + var organization1 = await organizationRepository.CreateAsync(new Organization + { + Name = $"Test Org {id}", + BillingEmail = $"test+{id}@example.com", + Plan = "Test", + PrivateKey = "privatekey", + + }); + + var organizationDomain = new OrganizationDomain + { + OrganizationId = organization1.Id, + DomainName = $"domain2+{id}@example.com", + Txt = "btw+12345" + }; + + var within36HoursWindow = 1; + organizationDomain.SetNextRunDate(within36HoursWindow); + organizationDomain.SetVerifiedDate(); + + await organizationDomainRepository.CreateAsync(organizationDomain); + + var date = DateTimeOffset.UtcNow.Date.AddDays(1); + + // Act + var domains = await organizationDomainRepository.GetManyByNextRunDateAsync(date); + + // Assert + var expectedDomain = domains.FirstOrDefault(domain => domain.DomainName == organizationDomain.DomainName); + Assert.Null(expectedDomain); + } } diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index d01e92ad4c..7c7f790cdc 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -14,6 +14,7 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; @@ -188,44 +189,27 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory // QUESTION: The normal licensing service should run fine on developer machines but not in CI // should we have a fork here to leave the normal service for developers? // TODO: Eventually add the license file to CI - var licensingService = services.First(sd => sd.ServiceType == typeof(ILicensingService)); - services.Remove(licensingService); - services.AddSingleton(); + Replace(services); // FUTURE CONSIDERATION: Add way to run this self hosted/cloud, for now it is cloud only - var pushRegistrationService = services.First(sd => sd.ServiceType == typeof(IPushRegistrationService)); - services.Remove(pushRegistrationService); - services.AddSingleton(); + Replace(services); // Even though we are cloud we currently set this up as cloud, we can use the EF/selfhosted service // instead of using Noop for this service // TODO: Install and use azurite in CI pipeline - var eventWriteService = services.First(sd => sd.ServiceType == typeof(IEventWriteService)); - services.Remove(eventWriteService); - services.AddSingleton(); + Replace(services); - var eventRepositoryService = services.First(sd => sd.ServiceType == typeof(IEventRepository)); - services.Remove(eventRepositoryService); - services.AddSingleton(); + Replace(services); - var mailDeliveryService = services.First(sd => sd.ServiceType == typeof(IMailDeliveryService)); - services.Remove(mailDeliveryService); - services.AddSingleton(); + Replace(services); - var captchaValidationService = services.First(sd => sd.ServiceType == typeof(ICaptchaValidationService)); - services.Remove(captchaValidationService); - services.AddSingleton(); + Replace(services); // TODO: Install and use azurite in CI pipeline - var installationDeviceRepository = - services.First(sd => sd.ServiceType == typeof(IInstallationDeviceRepository)); - services.Remove(installationDeviceRepository); - services.AddSingleton(); + Replace(services); // TODO: Install and use azurite in CI pipeline - var referenceEventService = services.First(sd => sd.ServiceType == typeof(IReferenceEventService)); - services.Remove(referenceEventService); - services.AddSingleton(); + Replace(services); // Our Rate limiter works so well that it begins to fail tests unless we carve out // one whitelisted ip. We should still test the rate limiter though and they should change the Ip @@ -245,14 +229,9 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory services.AddSingleton(); // Noop StripePaymentService - this could be changed to integrate with our Stripe test account - var stripePaymentService = services.First(sd => sd.ServiceType == typeof(IPaymentService)); - services.Remove(stripePaymentService); - services.AddSingleton(Substitute.For()); + Replace(services, Substitute.For()); - var organizationBillingService = - services.First(sd => sd.ServiceType == typeof(IOrganizationBillingService)); - services.Remove(organizationBillingService); - services.AddSingleton(Substitute.For()); + Replace(services, Substitute.For()); }); foreach (var configureTestService in _configureTestServices) @@ -261,6 +240,35 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory } } + private static void Replace(IServiceCollection services) + where TService : class + where TNewImplementation : class, TService + { + services.RemoveAll(); + services.AddSingleton(); + } + + private static void Replace(IServiceCollection services, TService implementation) + where TService : class + { + services.RemoveAll(); + services.AddSingleton(implementation); + } + + public HttpClient CreateAuthedClient(string accessToken) + { + var handler = Server.CreateHandler((context) => + { + context.Request.Headers.Authorization = $"Bearer {accessToken}"; + }); + + return new HttpClient(handler) + { + BaseAddress = Server.BaseAddress, + Timeout = TimeSpan.FromSeconds(200), + }; + } + public DatabaseContext GetDatabaseContext() { var scope = Services.CreateScope(); diff --git a/util/Migrator/DbScripts/2025-01-28_00_Add_Organization_ReadAddableToProviderByUserId.sql b/util/Migrator/DbScripts/2025-01-28_00_Add_Organization_ReadAddableToProviderByUserId.sql new file mode 100644 index 0000000000..1255544d19 --- /dev/null +++ b/util/Migrator/DbScripts/2025-01-28_00_Add_Organization_ReadAddableToProviderByUserId.sql @@ -0,0 +1,31 @@ +-- Drop existing SPROC +IF OBJECT_ID('[dbo].[Organization_ReadAddableToProviderByUserId') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Organization_ReadAddableToProviderByUserId] +END +GO + +CREATE PROCEDURE [dbo].[Organization_ReadAddableToProviderByUserId] + @UserId UNIQUEIDENTIFIER, + @ProviderType TINYINT +AS +BEGIN + SET NOCOUNT ON + SELECT O.* FROM [dbo].[OrganizationUser] AS OU + JOIN [dbo].[Organization] AS O ON O.[Id] = OU.[OrganizationId] + WHERE + OU.[UserId] = @UserId AND + OU.[Type] = 0 AND + OU.[Status] = 2 AND + O.[Enabled] = 1 AND + O.[GatewayCustomerId] IS NOT NULL AND + O.[GatewaySubscriptionId] IS NOT NULL AND + O.[Seats] > 0 AND + O.[Status] = 1 AND + O.[UseSecretsManager] = 0 AND + -- All Teams & Enterprise for MSP + (@ProviderType = 0 AND O.[PlanType] IN (2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20) OR + -- All Enterprise for MOE + @ProviderType = 2 AND O.[PlanType] IN (4, 5, 10, 11, 14, 15, 19, 20)); +END +GO diff --git a/util/Migrator/DbScripts/2025-01-28_00_UpdateOrganization_UnassignedToProviderSearch.sql b/util/Migrator/DbScripts/2025-01-28_00_UpdateOrganization_UnassignedToProviderSearch.sql new file mode 100644 index 0000000000..07ec9ae8ac --- /dev/null +++ b/util/Migrator/DbScripts/2025-01-28_00_UpdateOrganization_UnassignedToProviderSearch.sql @@ -0,0 +1,54 @@ +-- Drop existing SPROC +IF OBJECT_ID('[dbo].[Organization_UnassignedToProviderSearch]') IS NOT NULL + BEGIN + DROP PROCEDURE [dbo].[Organization_UnassignedToProviderSearch] + END +GO + +CREATE PROCEDURE [dbo].[Organization_UnassignedToProviderSearch] + @Name NVARCHAR(55), + @OwnerEmail NVARCHAR(256), + @Skip INT = 0, + @Take INT = 25 + WITH RECOMPILE +AS +BEGIN + SET NOCOUNT ON + DECLARE @NameLikeSearch NVARCHAR(55) = '%' + @Name + '%' + DECLARE @OwnerLikeSearch NVARCHAR(55) = @OwnerEmail + '%' + + IF @OwnerEmail IS NOT NULL + BEGIN + SELECT + O.* + FROM + [dbo].[OrganizationView] O + INNER JOIN + [dbo].[OrganizationUser] OU ON O.[Id] = OU.[OrganizationId] + INNER JOIN + [dbo].[User] U ON U.[Id] = OU.[UserId] + WHERE + O.[PlanType] NOT IN (0, 1, 6, 7) -- Not 'Free', 'Custom' or 'Families' + AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id]) + AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch) + AND (U.[Email] LIKE @OwnerLikeSearch) + ORDER BY O.[CreationDate] DESC, O.[Id] + OFFSET @Skip ROWS + FETCH NEXT @Take ROWS ONLY + END + ELSE + BEGIN + SELECT + O.* + FROM + [dbo].[OrganizationView] O + WHERE + O.[PlanType] NOT IN (0, 1, 6, 7) -- Not 'Free', 'Custom' or 'Families' + AND NOT EXISTS (SELECT * FROM [dbo].[ProviderOrganizationView] PO WHERE PO.[OrganizationId] = O.[Id]) + AND (@Name IS NULL OR O.[Name] LIKE @NameLikeSearch) + ORDER BY O.[CreationDate] DESC, O.[Id] + OFFSET @Skip ROWS + FETCH NEXT @Take ROWS ONLY + END +END +GO diff --git a/util/Migrator/DbScripts/2025-02-03_01_RefreshView_For_LimitItemDeletion.sql b/util/Migrator/DbScripts/2025-02-03_01_RefreshView_For_LimitItemDeletion.sql new file mode 100644 index 0000000000..98893bb030 --- /dev/null +++ b/util/Migrator/DbScripts/2025-02-03_01_RefreshView_For_LimitItemDeletion.sql @@ -0,0 +1,7 @@ +-- Refresh Views + +IF OBJECT_ID('[dbo].[OrganizationView]') IS NOT NULL + BEGIN + EXECUTE sp_refreshview N'[dbo].[OrganizationView]'; + END +GO