From 712926996e6b6c6ecd51097ba4eef731f52636d2 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Thu, 9 Oct 2025 18:54:14 +0200 Subject: [PATCH 01/14] PM-26727 - First iteration of Claude Code Review tuning (#6435) * Sticky comments + pre-load specific project files + align prompts with clients repo --- .github/workflows/review-code.yml | 21 ++++++++++++++++++--- CLAUDE.md | 25 +++++++++++++++---------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml index b49f5cec8f..83cbc3bb54 100644 --- a/.github/workflows/review-code.yml +++ b/.github/workflows/review-code.yml @@ -84,16 +84,18 @@ jobs: - name: Review with Claude Code if: steps.check_changes.outputs.vault_team_changes == 'true' - uses: anthropics/claude-code-action@a5528eec7426a4f0c9c1ac96018daa53ebd05bc4 # v1.0.7 + uses: anthropics/claude-code-action@ac1a3207f3f00b4a37e2f3a6f0935733c7c64651 # v1.0.11 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} track_progress: true + use_sticky_comment: true prompt: | REPO: ${{ github.repository }} PR NUMBER: ${{ github.event.pull_request.number }} TITLE: ${{ github.event.pull_request.title }} BODY: ${{ github.event.pull_request.body }} AUTHOR: ${{ github.event.pull_request.user.login }} + COMMIT: ${{ github.event.pull_request.head.sha }} Please review this pull request with a focus on: - Code quality and best practices @@ -103,7 +105,20 @@ jobs: Note: The PR branch is already checked out in the current working directory. - Provide detailed feedback using inline comments for specific issues. + Provide a comprehensive review including: + - Summary of changes since last review + - Critical issues found (be thorough) + - Suggested improvements (be thorough) + - Good practices observed (be concise - list only the most notable items without elaboration) + - Action items for the author + - Leverage collapsible
sections where appropriate for lengthy explanations or code snippets to enhance human readability + + When reviewing subsequent commits: + - Track status of previously identified issues (fixed/unfixed/reopened) + - Identify NEW problems introduced since last review + - Note if fixes introduced new issues + + IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively. claude_args: | - --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" + --allowedTools "mcp__github_comment__update_claude_comment,mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*)" diff --git a/CLAUDE.md b/CLAUDE.md index d07bd3f3e1..c1349e8c9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,24 +1,29 @@ # Bitwarden Server - Claude Code Configuration +## Project Context Files + +**Read these files before reviewing to ensure that you fully understand the project and contributing guidelines** + +1. @README.md +2. @CONTRIBUTING.md +3. @.github/PULL_REQUEST_TEMPLATE.md + ## Critical Rules -- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/` which are generated files - **NEVER** use code regions: If complexity suggests regions, refactor for better readability + - **NEVER** compromise zero-knowledge principles: User vault data must remain encrypted and inaccessible to Bitwarden + - **NEVER** log or expose sensitive data: No PII, passwords, keys, or vault data in logs or error messages + - **ALWAYS** use secure communication channels: Enforce confidentiality, integrity, and authenticity + - **ALWAYS** encrypt sensitive data: All vault data must be encrypted at rest, in transit, and in use + - **ALWAYS** prioritize cryptographic integrity and data protection + - **ALWAYS** add unit tests (with mocking) for any new feature development -## Project Context - -- **Architecture**: Feature and team-based organization -- **Framework**: .NET 8.0, ASP.NET Core -- **Database**: SQL Server primary, EF Core supports PostgreSQL, MySQL/MariaDB, SQLite -- **Testing**: xUnit, NSubstitute -- **Container**: Docker, Docker Compose, Kubernetes/Helm deployable - ## Project Structure - **Source Code**: `/src/` - Services and core infrastructure @@ -42,7 +47,7 @@ - **Database update**: `pwsh dev/migrate.ps1` - **Generate OpenAPI**: `pwsh dev/generate_openapi_files.ps1` -## Code Review Checklist +## Development Workflow - Security impact assessed - xUnit tests added / updated From 34f5ffd981fb00568617666cfdfd67c370ad54e8 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:20:28 -0500 Subject: [PATCH 02/14] [PM-26692] Count unverified setup intent as payment method during organization subscription creation (#6433) * Updated check that determines whether org has payment method to include bank account when determining how to set trial_settings * Run dotnet format --- .../Queries/GetOrganizationWarningsQuery.cs | 32 +-- .../Services/OrganizationBillingService.cs | 16 +- .../Payment/Queries/HasPaymentMethodQuery.cs | 58 ++++ src/Core/Billing/Payment/Registrations.cs | 1 + .../GetOrganizationWarningsQueryTests.cs | 22 +- .../Queries/HasPaymentMethodQueryTests.cs | 264 ++++++++++++++++++ 6 files changed, 339 insertions(+), 54 deletions(-) create mode 100644 src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs create mode 100644 test/Core.Test/Billing/Payment/Queries/HasPaymentMethodQueryTests.cs diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs index f33814f1cf..887a6badf5 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -2,11 +2,11 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Services; @@ -30,8 +30,8 @@ public interface IGetOrganizationWarningsQuery public class GetOrganizationWarningsQuery( ICurrentContext currentContext, + IHasPaymentMethodQuery hasPaymentMethodQuery, IProviderRepository providerRepository, - ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService) : IGetOrganizationWarningsQuery { @@ -81,15 +81,7 @@ public class GetOrganizationWarningsQuery( return null; } - var customer = subscription.Customer; - - var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(organization); - - var hasPaymentMethod = - !string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) || - !string.IsNullOrEmpty(customer.DefaultSourceId) || - hasUnverifiedBankAccount || - customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId); + var hasPaymentMethod = await hasPaymentMethodQuery.Run(organization); if (hasPaymentMethod) { @@ -287,22 +279,4 @@ public class GetOrganizationWarningsQuery( _ => null }; } - - private async Task HasUnverifiedBankAccountAsync( - Organization organization) - { - var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id); - - if (string.IsNullOrEmpty(setupIntentId)) - { - return false; - } - - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions - { - Expand = ["payment_method"] - }); - - return setupIntent.IsUnverifiedBankAccount(); - } } diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index ce8a9a877b..36a618f799 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -6,6 +6,7 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; @@ -27,6 +28,7 @@ namespace Bit.Core.Billing.Organizations.Services; public class OrganizationBillingService( IBraintreeGateway braintreeGateway, IGlobalSettings globalSettings, + IHasPaymentMethodQuery hasPaymentMethodQuery, ILogger logger, IOrganizationRepository organizationRepository, IPricingClient pricingClient, @@ -43,7 +45,7 @@ public class OrganizationBillingService( ? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType) : await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup); - var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup); + var subscription = await CreateSubscriptionAsync(organization, customer, subscriptionSetup); if (subscription.Status is StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active) { @@ -120,8 +122,7 @@ public class OrganizationBillingService( orgOccupiedSeats.Total); } - public async Task - UpdatePaymentMethod( + public async Task UpdatePaymentMethod( Organization organization, TokenizedPaymentSource tokenizedPaymentSource, TaxInformation taxInformation) @@ -397,7 +398,7 @@ public class OrganizationBillingService( } private async Task CreateSubscriptionAsync( - Guid organizationId, + Organization organization, Customer customer, SubscriptionSetup subscriptionSetup) { @@ -465,7 +466,7 @@ public class OrganizationBillingService( Items = subscriptionItemOptionsList, Metadata = new Dictionary { - ["organizationId"] = organizationId.ToString(), + ["organizationId"] = organization.Id.ToString(), ["trialInitiationPath"] = !string.IsNullOrEmpty(subscriptionSetup.InitiationPath) && subscriptionSetup.InitiationPath.Contains("trial from marketing website") ? "marketing-initiated" @@ -475,9 +476,10 @@ public class OrganizationBillingService( TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays }; + var hasPaymentMethod = await hasPaymentMethodQuery.Run(organization); + // Only set trial_settings.end_behavior.missing_payment_method to "cancel" if there is no payment method - if (string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) && - !customer.Metadata.ContainsKey(BraintreeCustomerIdKey)) + if (!hasPaymentMethod) { subscriptionCreateOptions.TrialSettings = new SubscriptionTrialSettingsOptions { diff --git a/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs b/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs new file mode 100644 index 0000000000..ec77ee0712 --- /dev/null +++ b/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs @@ -0,0 +1,58 @@ +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Services; +using Stripe; + +namespace Bit.Core.Billing.Payment.Queries; + +using static StripeConstants; + +public interface IHasPaymentMethodQuery +{ + Task Run(ISubscriber subscriber); +} + +public class HasPaymentMethodQuery( + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService) : IHasPaymentMethodQuery +{ + public async Task Run(ISubscriber subscriber) + { + var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(subscriber); + + var customer = await subscriberService.GetCustomer(subscriber); + + if (customer == null) + { + return hasUnverifiedBankAccount; + } + + return + !string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) || + !string.IsNullOrEmpty(customer.DefaultSourceId) || + hasUnverifiedBankAccount || + customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId); + } + + private async Task HasUnverifiedBankAccountAsync( + ISubscriber subscriber) + { + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id); + + if (string.IsNullOrEmpty(setupIntentId)) + { + return false; + } + + var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + { + Expand = ["payment_method"] + }); + + return setupIntent.IsUnverifiedBankAccount(); + } +} diff --git a/src/Core/Billing/Payment/Registrations.cs b/src/Core/Billing/Payment/Registrations.cs index 478673d2fc..89d3778ccd 100644 --- a/src/Core/Billing/Payment/Registrations.cs +++ b/src/Core/Billing/Payment/Registrations.cs @@ -19,5 +19,6 @@ public static class Registrations services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } } diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs index 5234d500d1..96f9c1496e 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs @@ -2,10 +2,10 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Services; @@ -75,7 +75,7 @@ public class GetOrganizationWarningsQueryTests }); sutProvider.GetDependency().EditSubscription(organization.Id).Returns(true); - sutProvider.GetDependency().GetSetupIntentIdForSubscriber(organization.Id).Returns((string?)null); + sutProvider.GetDependency().Run(organization).Returns(false); var response = await sutProvider.Sut.Run(organization); @@ -86,12 +86,11 @@ public class GetOrganizationWarningsQueryTests } [Theory, BitAutoData] - public async Task Run_Has_FreeTrialWarning_WithUnverifiedBankAccount_NoWarning( + public async Task Run_Has_FreeTrialWarning_WithPaymentMethod_NoWarning( Organization organization, SutProvider sutProvider) { var now = DateTime.UtcNow; - const string setupIntentId = "setup_intent_id"; sutProvider.GetDependency() .GetSubscription(organization, Arg.Is(options => @@ -113,20 +112,7 @@ public class GetOrganizationWarningsQueryTests }); sutProvider.GetDependency().EditSubscription(organization.Id).Returns(true); - sutProvider.GetDependency().GetSetupIntentIdForSubscriber(organization.Id).Returns(setupIntentId); - sutProvider.GetDependency().SetupIntentGet(setupIntentId, Arg.Is( - options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent - { - Status = "requires_action", - NextAction = new SetupIntentNextAction - { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() - }, - PaymentMethod = new PaymentMethod - { - UsBankAccount = new PaymentMethodUsBankAccount() - } - }); + sutProvider.GetDependency().Run(organization).Returns(true); var response = await sutProvider.Sut.Run(organization); diff --git a/test/Core.Test/Billing/Payment/Queries/HasPaymentMethodQueryTests.cs b/test/Core.Test/Billing/Payment/Queries/HasPaymentMethodQueryTests.cs new file mode 100644 index 0000000000..c7ab0c17ff --- /dev/null +++ b/test/Core.Test/Billing/Payment/Queries/HasPaymentMethodQueryTests.cs @@ -0,0 +1,264 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Services; +using Bit.Core.Services; +using Bit.Core.Test.Billing.Extensions; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Payment.Queries; + +using static StripeConstants; + +public class HasPaymentMethodQueryTests +{ + private readonly ISetupIntentCache _setupIntentCache = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ISubscriberService _subscriberService = Substitute.For(); + private readonly HasPaymentMethodQuery _query; + + public HasPaymentMethodQueryTests() + { + _query = new HasPaymentMethodQuery( + _setupIntentCache, + _stripeAdapter, + _subscriberService); + } + + [Fact] + public async Task Run_NoCustomer_ReturnsFalse() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + _subscriberService.GetCustomer(organization).ReturnsNull(); + _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.False(hasPaymentMethod); + } + + [Fact] + public async Task Run_NoCustomer_WithUnverifiedBankAccount_ReturnsTrue() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + _subscriberService.GetCustomer(organization).ReturnsNull(); + _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123"); + + _stripeAdapter + .SetupIntentGet("seti_123", + Arg.Is(options => options.HasExpansions("payment_method"))) + .Returns(new SetupIntent + { + Status = "requires_action", + NextAction = new SetupIntentNextAction + { + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + }, + PaymentMethod = new PaymentMethod + { + UsBankAccount = new PaymentMethodUsBankAccount() + } + }); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.True(hasPaymentMethod); + } + + [Fact] + public async Task Run_NoPaymentMethod_ReturnsFalse() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.False(hasPaymentMethod); + } + + [Fact] + public async Task Run_HasDefaultPaymentMethodId_ReturnsTrue() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings + { + DefaultPaymentMethodId = "pm_123" + }, + Metadata = new Dictionary() + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.True(hasPaymentMethod); + } + + [Fact] + public async Task Run_HasDefaultSourceId_ReturnsTrue() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + DefaultSourceId = "card_123", + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.True(hasPaymentMethod); + } + + [Fact] + public async Task Run_HasUnverifiedBankAccount_ReturnsTrue() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123"); + + _stripeAdapter + .SetupIntentGet("seti_123", + Arg.Is(options => options.HasExpansions("payment_method"))) + .Returns(new SetupIntent + { + Status = "requires_action", + NextAction = new SetupIntentNextAction + { + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + }, + PaymentMethod = new PaymentMethod + { + UsBankAccount = new PaymentMethodUsBankAccount() + } + }); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.True(hasPaymentMethod); + } + + [Fact] + public async Task Run_HasBraintreeCustomerId_ReturnsTrue() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary + { + [MetadataKeys.BraintreeCustomerId] = "braintree_customer_id" + } + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.True(hasPaymentMethod); + } + + [Fact] + public async Task Run_NoSetupIntentId_ReturnsFalse() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.False(hasPaymentMethod); + } + + [Fact] + public async Task Run_SetupIntentNotBankAccount_ReturnsFalse() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123"); + + _stripeAdapter + .SetupIntentGet("seti_123", + Arg.Is(options => options.HasExpansions("payment_method"))) + .Returns(new SetupIntent + { + PaymentMethod = new PaymentMethod + { + Type = "card" + }, + Status = "succeeded" + }); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.False(hasPaymentMethod); + } +} From 3bef57259db4895ba75e3bd55920caadb193c2b6 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:50:07 -0500 Subject: [PATCH 03/14] [PM-25379] Refactor org metadata (#6418) * ignore serena * removing unused properties from org metadata * removing further properties that can already be fetched on the client side using available data * new vnext endpoint for org metadata plus caching metadata first pass including new feature flag # Conflicts: # src/Core/Constants.cs * [PM-25379] decided against cache and new query shouldn't use the service * pr feedback removing unneeded response model * run dotnet format --- .gitignore | 1 + .../OrganizationBillingController.cs | 4 +- .../OrganizationBillingVNextController.cs | 19 + .../Responses/OrganizationMetadataResponse.cs | 31 -- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Models/OrganizationMetadata.cs | 18 - .../Queries/GetOrganizationMetadataQuery.cs | 95 +++++ .../Services/OrganizationBillingService.cs | 32 +- src/Core/Constants.cs | 1 + .../OrganizationBillingControllerTests.cs | 12 +- .../GetOrganizationMetadataQueryTests.cs | 369 ++++++++++++++++++ .../OrganizationBillingServiceTests.cs | 12 +- 12 files changed, 498 insertions(+), 97 deletions(-) delete mode 100644 src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs create mode 100644 src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs create mode 100644 test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs diff --git a/.gitignore b/.gitignore index 3b1f40e673..fd24c7713d 100644 --- a/.gitignore +++ b/.gitignore @@ -231,3 +231,4 @@ bitwarden_license/src/Sso/Sso.zip /identity.json /api.json /api.public.json +.serena/ diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 1d6bf51661..6e4cacc155 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -38,9 +38,7 @@ public class OrganizationBillingController( return Error.NotFound(); } - var response = OrganizationMetadataResponse.From(metadata); - - return TypedResults.Ok(response); + return TypedResults.Ok(metadata); } [HttpGet("history")] diff --git a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs index 2f825f2cb9..64ec068a5e 100644 --- a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs @@ -4,6 +4,7 @@ using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Api.Billing.Models.Requests.Subscriptions; using Bit.Api.Billing.Models.Requirements; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Organizations.Queries; @@ -25,6 +26,7 @@ public class OrganizationBillingVNextController( ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, IGetBillingAddressQuery getBillingAddressQuery, IGetCreditQuery getCreditQuery, + IGetOrganizationMetadataQuery getOrganizationMetadataQuery, IGetOrganizationWarningsQuery getOrganizationWarningsQuery, IGetPaymentMethodQuery getPaymentMethodQuery, IRestartSubscriptionCommand restartSubscriptionCommand, @@ -113,6 +115,23 @@ public class OrganizationBillingVNextController( return Handle(result); } + [Authorize] + [HttpGet("metadata")] + [RequireFeature(FeatureFlagKeys.PM25379_UseNewOrganizationMetadataStructure)] + [InjectOrganization] + public async Task GetMetadataAsync( + [BindNever] Organization organization) + { + var metadata = await getOrganizationMetadataQuery.Run(organization); + + if (metadata == null) + { + return TypedResults.NotFound(); + } + + return TypedResults.Ok(metadata); + } + [Authorize] [HttpGet("warnings")] [InjectOrganization] diff --git a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs deleted file mode 100644 index a13f267c3b..0000000000 --- a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Bit.Core.Billing.Organizations.Models; - -namespace Bit.Api.Billing.Models.Responses; - -public record OrganizationMetadataResponse( - bool IsEligibleForSelfHost, - bool IsManaged, - bool IsOnSecretsManagerStandalone, - bool IsSubscriptionUnpaid, - bool HasSubscription, - bool HasOpenInvoice, - bool IsSubscriptionCanceled, - DateTime? InvoiceDueDate, - DateTime? InvoiceCreatedDate, - DateTime? SubPeriodEndDate, - int OrganizationOccupiedSeats) -{ - public static OrganizationMetadataResponse From(OrganizationMetadata metadata) - => new( - metadata.IsEligibleForSelfHost, - metadata.IsManaged, - metadata.IsOnSecretsManagerStandalone, - metadata.IsSubscriptionUnpaid, - metadata.HasSubscription, - metadata.HasOpenInvoice, - metadata.IsSubscriptionCanceled, - metadata.InvoiceDueDate, - metadata.InvoiceCreatedDate, - metadata.SubPeriodEndDate, - metadata.OrganizationOccupiedSeats); -} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 7aec422a4b..d6593f5365 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -31,6 +31,7 @@ public static class ServiceCollectionExtensions services.AddPaymentOperations(); services.AddOrganizationLicenseCommandsQueries(); services.AddPremiumCommands(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Core/Billing/Organizations/Models/OrganizationMetadata.cs b/src/Core/Billing/Organizations/Models/OrganizationMetadata.cs index 2bcd213dbf..fedd0ad78c 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationMetadata.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationMetadata.cs @@ -1,28 +1,10 @@ namespace Bit.Core.Billing.Organizations.Models; public record OrganizationMetadata( - bool IsEligibleForSelfHost, - bool IsManaged, bool IsOnSecretsManagerStandalone, - bool IsSubscriptionUnpaid, - bool HasSubscription, - bool HasOpenInvoice, - bool IsSubscriptionCanceled, - DateTime? InvoiceDueDate, - DateTime? InvoiceCreatedDate, - DateTime? SubPeriodEndDate, int OrganizationOccupiedSeats) { public static OrganizationMetadata Default => new OrganizationMetadata( false, - false, - false, - false, - false, - false, - false, - null, - null, - null, 0); } diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs new file mode 100644 index 0000000000..63da0477a1 --- /dev/null +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs @@ -0,0 +1,95 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Stripe; + +namespace Bit.Core.Billing.Organizations.Queries; + +public interface IGetOrganizationMetadataQuery +{ + Task Run(Organization organization); +} + +public class GetOrganizationMetadataQuery( + IGlobalSettings globalSettings, + IOrganizationRepository organizationRepository, + IPricingClient pricingClient, + ISubscriberService subscriberService) : IGetOrganizationMetadataQuery +{ + public async Task Run(Organization organization) + { + if (organization == null) + { + return null; + } + + if (globalSettings.SelfHosted) + { + return OrganizationMetadata.Default; + } + + var orgOccupiedSeats = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + + if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) + { + return OrganizationMetadata.Default with + { + OrganizationOccupiedSeats = orgOccupiedSeats.Total + }; + } + + var customer = await subscriberService.GetCustomer(organization, + new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] }); + + var subscription = await subscriberService.GetSubscription(organization); + + if (customer == null || subscription == null) + { + return OrganizationMetadata.Default with + { + OrganizationOccupiedSeats = orgOccupiedSeats.Total + }; + } + + var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription); + + return new OrganizationMetadata( + isOnSecretsManagerStandalone, + orgOccupiedSeats.Total); + } + + private async Task IsOnSecretsManagerStandalone( + Organization organization, + Customer? customer, + Subscription? subscription) + { + if (customer == null || subscription == null) + { + return false; + } + + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + if (!plan.SupportsSecretsManager) + { + return false; + } + + var hasCoupon = customer.Discount?.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone; + + if (!hasCoupon) + { + return false; + } + + var subscriptionProductIds = subscription.Items.Data.Select(item => item.Plan.ProductId); + + var couponAppliesTo = customer.Discount?.Coupon?.AppliesTo?.Products; + + return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); + } +} diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 36a618f799..494a202c4b 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -74,16 +74,12 @@ public class OrganizationBillingService( return OrganizationMetadata.Default; } - var isEligibleForSelfHost = await IsEligibleForSelfHostAsync(organization); - - var isManaged = organization.Status == OrganizationStatusType.Managed; var orgOccupiedSeats = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { return OrganizationMetadata.Default with { - IsEligibleForSelfHost = isEligibleForSelfHost, - IsManaged = isManaged, OrganizationOccupiedSeats = orgOccupiedSeats.Total }; } @@ -97,28 +93,14 @@ public class OrganizationBillingService( { return OrganizationMetadata.Default with { - IsEligibleForSelfHost = isEligibleForSelfHost, - IsManaged = isManaged + OrganizationOccupiedSeats = orgOccupiedSeats.Total }; } var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription); - var invoice = !string.IsNullOrEmpty(subscription.LatestInvoiceId) - ? await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions()) - : null; - return new OrganizationMetadata( - isEligibleForSelfHost, - isManaged, isOnSecretsManagerStandalone, - subscription.Status == StripeConstants.SubscriptionStatus.Unpaid, - true, - invoice?.Status == StripeConstants.InvoiceStatus.Open, - subscription.Status == StripeConstants.SubscriptionStatus.Canceled, - invoice?.DueDate, - invoice?.Created, - subscription.CurrentPeriodEnd, orgOccupiedSeats.Total); } @@ -536,16 +518,6 @@ public class OrganizationBillingService( return customer; } - private async Task IsEligibleForSelfHostAsync( - Organization organization) - { - var plans = await pricingClient.ListPlans(); - - var eligibleSelfHostPlans = plans.Where(plan => plan.HasSelfHost).Select(plan => plan.Type); - - return eligibleSelfHostPlans.Contains(organization.PlanType); - } - private async Task IsOnSecretsManagerStandalone( Organization organization, Customer? customer, diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 80b74877c5..a3c637ecb1 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -179,6 +179,7 @@ public static class FeatureFlagKeys public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings"; + public const string PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure"; public const string PM24996ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog"; public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button"; public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog"; diff --git a/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs index 51866320ee..d79bfde893 100644 --- a/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs @@ -1,5 +1,4 @@ using Bit.Api.Billing.Controllers; -using Bit.Api.Billing.Models.Responses; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.Models; @@ -53,19 +52,16 @@ public class OrganizationBillingControllerTests { sutProvider.GetDependency().OrganizationUser(organizationId).Returns(true); sutProvider.GetDependency().GetMetadata(organizationId) - .Returns(new OrganizationMetadata(true, true, true, true, true, true, true, null, null, null, 0)); + .Returns(new OrganizationMetadata(true, 10)); var result = await sutProvider.Sut.GetMetadataAsync(organizationId); - Assert.IsType>(result); + Assert.IsType>(result); - var response = ((Ok)result).Value; + var response = ((Ok)result).Value; - Assert.True(response.IsEligibleForSelfHost); - Assert.True(response.IsManaged); Assert.True(response.IsOnSecretsManagerStandalone); - Assert.True(response.IsSubscriptionUnpaid); - Assert.True(response.HasSubscription); + Assert.Equal(10, response.OrganizationOccupiedSeats); } [Theory, BitAutoData] diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs new file mode 100644 index 0000000000..21081112d7 --- /dev/null +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs @@ -0,0 +1,369 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Organizations.Queries; + +[SutProviderCustomize] +public class GetOrganizationMetadataQueryTests +{ + [Theory, BitAutoData] + public async Task Run_NullOrganization_ReturnsNull( + SutProvider sutProvider) + { + var result = await sutProvider.Sut.Run(null); + + Assert.Null(result); + } + + [Theory, BitAutoData] + public async Task Run_SelfHosted_ReturnsDefault( + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency().SelfHosted.Returns(true); + + var result = await sutProvider.Sut.Run(organization); + + Assert.Equal(OrganizationMetadata.Default, result); + } + + [Theory, BitAutoData] + public async Task Run_NoGatewaySubscriptionId_ReturnsDefaultWithOccupiedSeats( + Organization organization, + SutProvider sutProvider) + { + organization.GatewaySubscriptionId = null; + + sutProvider.GetDependency().SelfHosted.Returns(false); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Users = 10, Sponsored = 0 }); + + var result = await sutProvider.Sut.Run(organization); + + Assert.NotNull(result); + Assert.False(result.IsOnSecretsManagerStandalone); + Assert.Equal(10, result.OrganizationOccupiedSeats); + } + + [Theory, BitAutoData] + public async Task Run_NullCustomer_ReturnsDefaultWithOccupiedSeats( + Organization organization, + SutProvider sutProvider) + { + organization.GatewaySubscriptionId = "sub_123"; + + sutProvider.GetDependency().SelfHosted.Returns(false); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Users = 5, Sponsored = 0 }); + + sutProvider.GetDependency() + .GetCustomer(organization, Arg.Is(options => + options.Expand.Contains("discount.coupon.applies_to"))) + .ReturnsNull(); + + var result = await sutProvider.Sut.Run(organization); + + Assert.NotNull(result); + Assert.False(result.IsOnSecretsManagerStandalone); + Assert.Equal(5, result.OrganizationOccupiedSeats); + } + + [Theory, BitAutoData] + public async Task Run_NullSubscription_ReturnsDefaultWithOccupiedSeats( + Organization organization, + SutProvider sutProvider) + { + organization.GatewaySubscriptionId = "sub_123"; + + var customer = new Customer(); + + sutProvider.GetDependency().SelfHosted.Returns(false); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Users = 7, Sponsored = 0 }); + + sutProvider.GetDependency() + .GetCustomer(organization, Arg.Is(options => + options.Expand.Contains("discount.coupon.applies_to"))) + .Returns(customer); + + sutProvider.GetDependency() + .GetSubscription(organization) + .ReturnsNull(); + + var result = await sutProvider.Sut.Run(organization); + + Assert.NotNull(result); + Assert.False(result.IsOnSecretsManagerStandalone); + Assert.Equal(7, result.OrganizationOccupiedSeats); + } + + [Theory, BitAutoData] + public async Task Run_WithSecretsManagerStandaloneCoupon_ReturnsMetadataWithFlag( + Organization organization, + SutProvider sutProvider) + { + organization.GatewaySubscriptionId = "sub_123"; + organization.PlanType = PlanType.EnterpriseAnnually; + + var productId = "product_123"; + var customer = new Customer + { + Discount = new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.SecretsManagerStandalone, + AppliesTo = new CouponAppliesTo + { + Products = [productId] + } + } + } + }; + + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Plan = new Plan + { + ProductId = productId + } + } + ] + } + }; + + sutProvider.GetDependency().SelfHosted.Returns(false); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Users = 15, Sponsored = 0 }); + + sutProvider.GetDependency() + .GetCustomer(organization, Arg.Is(options => + options.Expand.Contains("discount.coupon.applies_to"))) + .Returns(customer); + + sutProvider.GetDependency() + .GetSubscription(organization) + .Returns(subscription); + + sutProvider.GetDependency() + .GetPlanOrThrow(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); + + var result = await sutProvider.Sut.Run(organization); + + Assert.NotNull(result); + Assert.True(result.IsOnSecretsManagerStandalone); + Assert.Equal(15, result.OrganizationOccupiedSeats); + } + + [Theory, BitAutoData] + public async Task Run_WithoutSecretsManagerStandaloneCoupon_ReturnsMetadataWithoutFlag( + Organization organization, + SutProvider sutProvider) + { + organization.GatewaySubscriptionId = "sub_123"; + organization.PlanType = PlanType.TeamsAnnually; + + var customer = new Customer + { + Discount = null + }; + + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Plan = new Plan + { + ProductId = "product_123" + } + } + ] + } + }; + + sutProvider.GetDependency().SelfHosted.Returns(false); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Users = 20, Sponsored = 0 }); + + sutProvider.GetDependency() + .GetCustomer(organization, Arg.Is(options => + options.Expand.Contains("discount.coupon.applies_to"))) + .Returns(customer); + + sutProvider.GetDependency() + .GetSubscription(organization) + .Returns(subscription); + + sutProvider.GetDependency() + .GetPlanOrThrow(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); + + var result = await sutProvider.Sut.Run(organization); + + Assert.NotNull(result); + Assert.False(result.IsOnSecretsManagerStandalone); + Assert.Equal(20, result.OrganizationOccupiedSeats); + } + + [Theory, BitAutoData] + public async Task Run_CouponDoesNotApplyToSubscriptionProducts_ReturnsFalseForStandaloneFlag( + Organization organization, + SutProvider sutProvider) + { + organization.GatewaySubscriptionId = "sub_123"; + organization.PlanType = PlanType.EnterpriseAnnually; + + var customer = new Customer + { + Discount = new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.SecretsManagerStandalone, + AppliesTo = new CouponAppliesTo + { + Products = ["different_product_id"] + } + } + } + }; + + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Plan = new Plan + { + ProductId = "product_123" + } + } + ] + } + }; + + sutProvider.GetDependency().SelfHosted.Returns(false); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Users = 12, Sponsored = 0 }); + + sutProvider.GetDependency() + .GetCustomer(organization, Arg.Is(options => + options.Expand.Contains("discount.coupon.applies_to"))) + .Returns(customer); + + sutProvider.GetDependency() + .GetSubscription(organization) + .Returns(subscription); + + sutProvider.GetDependency() + .GetPlanOrThrow(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); + + var result = await sutProvider.Sut.Run(organization); + + Assert.NotNull(result); + Assert.False(result.IsOnSecretsManagerStandalone); + Assert.Equal(12, result.OrganizationOccupiedSeats); + } + + [Theory, BitAutoData] + public async Task Run_PlanDoesNotSupportSecretsManager_ReturnsFalseForStandaloneFlag( + Organization organization, + SutProvider sutProvider) + { + organization.GatewaySubscriptionId = "sub_123"; + organization.PlanType = PlanType.FamiliesAnnually; + + var productId = "product_123"; + var customer = new Customer + { + Discount = new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.SecretsManagerStandalone, + AppliesTo = new CouponAppliesTo + { + Products = [productId] + } + } + } + }; + + var subscription = new Subscription + { + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + Plan = new Plan + { + ProductId = productId + } + } + ] + } + }; + + sutProvider.GetDependency().SelfHosted.Returns(false); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Users = 8, Sponsored = 0 }); + + sutProvider.GetDependency() + .GetCustomer(organization, Arg.Is(options => + options.Expand.Contains("discount.coupon.applies_to"))) + .Returns(customer); + + sutProvider.GetDependency() + .GetSubscription(organization) + .Returns(subscription); + + sutProvider.GetDependency() + .GetPlanOrThrow(organization.PlanType) + .Returns(StaticStore.GetPlan(organization.PlanType)); + + var result = await sutProvider.Sut.Run(organization); + + Assert.NotNull(result); + Assert.False(result.IsOnSecretsManagerStandalone); + Assert.Equal(8, result.OrganizationOccupiedSeats); + } +} diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index 7edc60a26a..77dce8101c 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -96,6 +96,10 @@ public class OrganizationBillingServiceTests sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) .Returns(StaticStore.GetPlan(organization.PlanType)); + sutProvider.GetDependency() + .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) + .Returns(new OrganizationSeatCounts { Users = 1, Sponsored = 0 }); + var subscriberService = sutProvider.GetDependency(); // Set up subscriber service to return null for customer @@ -110,13 +114,7 @@ public class OrganizationBillingServiceTests Assert.NotNull(metadata); Assert.False(metadata!.IsOnSecretsManagerStandalone); - Assert.False(metadata.HasSubscription); - Assert.False(metadata.IsSubscriptionUnpaid); - Assert.False(metadata.HasOpenInvoice); - Assert.False(metadata.IsSubscriptionCanceled); - Assert.Null(metadata.InvoiceDueDate); - Assert.Null(metadata.InvoiceCreatedDate); - Assert.Null(metadata.SubPeriodEndDate); + Assert.Equal(1, metadata.OrganizationOccupiedSeats); } #endregion From b39e486e08d9e6032110efd791889a9982a6bf2b Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Fri, 10 Oct 2025 09:14:27 -0400 Subject: [PATCH 04/14] Switch to using built in source link feature (#6297) * Switch to using built in source link feature * Switch to using types assembly * Formatting * Make version retrieval safer * Add debug message * Apply suggestions from code review Co-authored-by: Matt Bishop --------- Co-authored-by: Matt Bishop --- Directory.Build.props | 17 ------ src/Core/Utilities/AssemblyHelpers.cs | 54 +++++++++---------- .../Utilities/AssemblyHelpersTests.cs | 18 +++++++ 3 files changed, 45 insertions(+), 44 deletions(-) create mode 100644 test/Core.Test/Utilities/AssemblyHelpersTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 76f35e297e..4aa72f3e81 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,8 +7,6 @@ Bit.$(MSBuildProjectName) enable - false - true annotations enable @@ -32,19 +30,4 @@ 4.18.1 - - - - - - - - - - - <_Parameter1>GitHash - <_Parameter2>$(SourceRevisionId) - - - \ No newline at end of file diff --git a/src/Core/Utilities/AssemblyHelpers.cs b/src/Core/Utilities/AssemblyHelpers.cs index 0cc01efdf3..03f7ff986d 100644 --- a/src/Core/Utilities/AssemblyHelpers.cs +++ b/src/Core/Utilities/AssemblyHelpers.cs @@ -1,46 +1,46 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - +using System.Diagnostics; using System.Reflection; namespace Bit.Core.Utilities; public static class AssemblyHelpers { - private static readonly IEnumerable _assemblyMetadataAttributes; - private static readonly AssemblyInformationalVersionAttribute _assemblyInformationalVersionAttributes; - private const string GIT_HASH_ASSEMBLY_KEY = "GitHash"; - private static string _version; - private static string _gitHash; + private static string? _version; + private static string? _gitHash; static AssemblyHelpers() { - _assemblyMetadataAttributes = Assembly.GetEntryAssembly().GetCustomAttributes(); - _assemblyInformationalVersionAttributes = Assembly.GetEntryAssembly().GetCustomAttribute(); - } - - public static string GetVersion() - { - if (string.IsNullOrWhiteSpace(_version)) + var assemblyInformationalVersionAttribute = typeof(AssemblyHelpers).Assembly.GetCustomAttribute(); + if (assemblyInformationalVersionAttribute == null) { - _version = _assemblyInformationalVersionAttributes.InformationalVersion; + Debug.Fail("The AssemblyInformationalVersionAttribute is expected to exist in this assembly, possibly its generation was turned off."); + return; } + var informationalVersion = assemblyInformationalVersionAttribute.InformationalVersion.AsSpan(); + + if (!informationalVersion.TrySplitBy('+', out var version, out var gitHash)) + { + // Treat the whole thing as the version + _version = informationalVersion.ToString(); + return; + } + + _version = version.ToString(); + if (gitHash.Length < 8) + { + return; + } + _gitHash = gitHash[..8].ToString(); + } + + public static string? GetVersion() + { return _version; } - public static string GetGitHash() + public static string? GetGitHash() { - if (string.IsNullOrWhiteSpace(_gitHash)) - { - try - { - _gitHash = _assemblyMetadataAttributes.Where(i => i.Key == GIT_HASH_ASSEMBLY_KEY).First().Value; - } - catch (Exception) - { } - } - return _gitHash; } } diff --git a/test/Core.Test/Utilities/AssemblyHelpersTests.cs b/test/Core.Test/Utilities/AssemblyHelpersTests.cs new file mode 100644 index 0000000000..463deb54a1 --- /dev/null +++ b/test/Core.Test/Utilities/AssemblyHelpersTests.cs @@ -0,0 +1,18 @@ +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.Utilities; + +public class AssemblyHelpersTests +{ + [Fact] + public void ReturnsValidVersionAndGitHash() + { + var version = AssemblyHelpers.GetVersion(); + _ = Version.Parse(version); + + var gitHash = AssemblyHelpers.GetGitHash(); + Assert.NotNull(gitHash); + Assert.Equal(8, gitHash.Length); + } +} From c9970a0782c21175859784cb95cac2559a72fce0 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 10 Oct 2025 08:19:45 -0500 Subject: [PATCH 05/14] Resolve tax estimation for Families scenarios (#6437) --- .../Commands/PreviewOrganizationTaxCommand.cs | 11 +- .../PreviewOrganizationTaxCommandTests.cs | 154 +++++++++++++++++- 2 files changed, 154 insertions(+), 11 deletions(-) diff --git a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs index 041e9bdbad..77bbe655c4 100644 --- a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs +++ b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs @@ -135,6 +135,8 @@ public class PreviewOrganizationTaxCommand( var newPlan = await pricingClient.GetPlanOrThrow(planChange.PlanType); + var quantity = newPlan.HasNonSeatBasedPasswordManagerPlan() ? 1 : 2; + var items = new List { new () @@ -142,7 +144,7 @@ public class PreviewOrganizationTaxCommand( Price = newPlan.HasNonSeatBasedPasswordManagerPlan() ? newPlan.PasswordManager.StripePlanId : newPlan.PasswordManager.StripeSeatPlanId, - Quantity = 2 + Quantity = quantity } }; @@ -194,12 +196,17 @@ public class PreviewOrganizationTaxCommand( ? currentPlan.PasswordManager.StripePlanId : currentPlan.PasswordManager.StripeSeatPlanId]; + var quantity = currentPlan.HasNonSeatBasedPasswordManagerPlan() && + !newPlan.HasNonSeatBasedPasswordManagerPlan() + ? (long)organization.Seats! + : passwordManagerSeats.Quantity; + items.Add(new InvoiceSubscriptionDetailsItemOptions { Price = newPlan.HasNonSeatBasedPasswordManagerPlan() ? newPlan.PasswordManager.StripePlanId : newPlan.PasswordManager.StripeSeatPlanId, - Quantity = passwordManagerSeats.Quantity + Quantity = quantity }); var hasStorage = diff --git a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs index 8e3cd5a0fa..08c3d9cf18 100644 --- a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs @@ -474,7 +474,149 @@ public class PreviewOrganizationTaxCommandTests options.CustomerDetails.TaxExempt == TaxExempt.None && options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" && - options.SubscriptionDetails.Items[0].Quantity == 2 && + options.SubscriptionDetails.Items[0].Quantity == 1 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_FamiliesOrganizationToTeams_UsesOrganizationSeats() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.FamiliesAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = false, + Seats = 6 + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "10012" + }; + + var currentPlan = new FamiliesPlan(); + var newPlan = new TeamsPlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + var subscriptionItems = new List + { + new() { Price = new Price { Id = "2020-families-org-annually" }, Quantity = 1 } + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Items = new StripeList { Data = subscriptionItems }, + Customer = new Customer { Discount = null } + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 900, + Total = 9900 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(9.00m, tax); + Assert.Equal(99.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "10012" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-annually" && + options.SubscriptionDetails.Items[0].Quantity == 6 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_FamiliesOrganizationToEnterprise_UsesOrganizationSeats() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.FamiliesAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = false, + Seats = 6 + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "10012" + }; + + var currentPlan = new FamiliesPlan(); + var newPlan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + var subscriptionItems = new List + { + new() { Price = new Price { Id = "2020-families-org-annually" }, Quantity = 1 } + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Items = new StripeList { Data = subscriptionItems }, + Customer = new Customer { Discount = null } + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 1200, + Total = 13200 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(12.00m, tax); + Assert.Equal(132.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "10012" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-enterprise-org-seat-annually" && + options.SubscriptionDetails.Items[0].Quantity == 6 && options.Coupon == null)); } @@ -956,10 +1098,7 @@ public class PreviewOrganizationTaxCommandTests Discount = null, TaxIds = new StripeList { - Data = new List - { - new() { Type = "gb_vat", Value = "GB123456789" } - } + Data = [new TaxId { Type = "gb_vat", Value = "GB123456789" }] } }; @@ -1040,10 +1179,7 @@ public class PreviewOrganizationTaxCommandTests }, TaxIds = new StripeList { - Data = new List - { - new() { Type = TaxIdType.SpanishNIF, Value = "12345678Z" } - } + Data = [new TaxId { Type = TaxIdType.SpanishNIF, Value = "12345678Z" }] } }; From 3272586e312f6045e5e49649f4da4cb217197369 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Fri, 10 Oct 2025 09:06:58 -0500 Subject: [PATCH 06/14] Revert "[PM-25379] Refactor org metadata (#6418)" (#6439) This reverts commit 3bef57259db4895ba75e3bd55920caadb193c2b6. --- .gitignore | 1 - .../OrganizationBillingController.cs | 4 +- .../OrganizationBillingVNextController.cs | 19 - .../Responses/OrganizationMetadataResponse.cs | 31 ++ .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Models/OrganizationMetadata.cs | 18 + .../Queries/GetOrganizationMetadataQuery.cs | 95 ----- .../Services/OrganizationBillingService.cs | 32 +- src/Core/Constants.cs | 1 - .../OrganizationBillingControllerTests.cs | 12 +- .../GetOrganizationMetadataQueryTests.cs | 369 ------------------ .../OrganizationBillingServiceTests.cs | 12 +- 12 files changed, 97 insertions(+), 498 deletions(-) create mode 100644 src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs delete mode 100644 src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs delete mode 100644 test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs diff --git a/.gitignore b/.gitignore index fd24c7713d..3b1f40e673 100644 --- a/.gitignore +++ b/.gitignore @@ -231,4 +231,3 @@ bitwarden_license/src/Sso/Sso.zip /identity.json /api.json /api.public.json -.serena/ diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 6e4cacc155..1d6bf51661 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -38,7 +38,9 @@ public class OrganizationBillingController( return Error.NotFound(); } - return TypedResults.Ok(metadata); + var response = OrganizationMetadataResponse.From(metadata); + + return TypedResults.Ok(response); } [HttpGet("history")] diff --git a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs index 64ec068a5e..2f825f2cb9 100644 --- a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs @@ -4,7 +4,6 @@ using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Api.Billing.Models.Requests.Subscriptions; using Bit.Api.Billing.Models.Requirements; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Organizations.Queries; @@ -26,7 +25,6 @@ public class OrganizationBillingVNextController( ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, IGetBillingAddressQuery getBillingAddressQuery, IGetCreditQuery getCreditQuery, - IGetOrganizationMetadataQuery getOrganizationMetadataQuery, IGetOrganizationWarningsQuery getOrganizationWarningsQuery, IGetPaymentMethodQuery getPaymentMethodQuery, IRestartSubscriptionCommand restartSubscriptionCommand, @@ -115,23 +113,6 @@ public class OrganizationBillingVNextController( return Handle(result); } - [Authorize] - [HttpGet("metadata")] - [RequireFeature(FeatureFlagKeys.PM25379_UseNewOrganizationMetadataStructure)] - [InjectOrganization] - public async Task GetMetadataAsync( - [BindNever] Organization organization) - { - var metadata = await getOrganizationMetadataQuery.Run(organization); - - if (metadata == null) - { - return TypedResults.NotFound(); - } - - return TypedResults.Ok(metadata); - } - [Authorize] [HttpGet("warnings")] [InjectOrganization] diff --git a/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs new file mode 100644 index 0000000000..a13f267c3b --- /dev/null +++ b/src/Api/Billing/Models/Responses/OrganizationMetadataResponse.cs @@ -0,0 +1,31 @@ +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Responses; + +public record OrganizationMetadataResponse( + bool IsEligibleForSelfHost, + bool IsManaged, + bool IsOnSecretsManagerStandalone, + bool IsSubscriptionUnpaid, + bool HasSubscription, + bool HasOpenInvoice, + bool IsSubscriptionCanceled, + DateTime? InvoiceDueDate, + DateTime? InvoiceCreatedDate, + DateTime? SubPeriodEndDate, + int OrganizationOccupiedSeats) +{ + public static OrganizationMetadataResponse From(OrganizationMetadata metadata) + => new( + metadata.IsEligibleForSelfHost, + metadata.IsManaged, + metadata.IsOnSecretsManagerStandalone, + metadata.IsSubscriptionUnpaid, + metadata.HasSubscription, + metadata.HasOpenInvoice, + metadata.IsSubscriptionCanceled, + metadata.InvoiceDueDate, + metadata.InvoiceCreatedDate, + metadata.SubPeriodEndDate, + metadata.OrganizationOccupiedSeats); +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index d6593f5365..7aec422a4b 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -31,7 +31,6 @@ public static class ServiceCollectionExtensions services.AddPaymentOperations(); services.AddOrganizationLicenseCommandsQueries(); services.AddPremiumCommands(); - services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Core/Billing/Organizations/Models/OrganizationMetadata.cs b/src/Core/Billing/Organizations/Models/OrganizationMetadata.cs index fedd0ad78c..2bcd213dbf 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationMetadata.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationMetadata.cs @@ -1,10 +1,28 @@ namespace Bit.Core.Billing.Organizations.Models; public record OrganizationMetadata( + bool IsEligibleForSelfHost, + bool IsManaged, bool IsOnSecretsManagerStandalone, + bool IsSubscriptionUnpaid, + bool HasSubscription, + bool HasOpenInvoice, + bool IsSubscriptionCanceled, + DateTime? InvoiceDueDate, + DateTime? InvoiceCreatedDate, + DateTime? SubPeriodEndDate, int OrganizationOccupiedSeats) { public static OrganizationMetadata Default => new OrganizationMetadata( false, + false, + false, + false, + false, + false, + false, + null, + null, + null, 0); } diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs deleted file mode 100644 index 63da0477a1..0000000000 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Organizations.Models; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; -using Bit.Core.Repositories; -using Bit.Core.Settings; -using Stripe; - -namespace Bit.Core.Billing.Organizations.Queries; - -public interface IGetOrganizationMetadataQuery -{ - Task Run(Organization organization); -} - -public class GetOrganizationMetadataQuery( - IGlobalSettings globalSettings, - IOrganizationRepository organizationRepository, - IPricingClient pricingClient, - ISubscriberService subscriberService) : IGetOrganizationMetadataQuery -{ - public async Task Run(Organization organization) - { - if (organization == null) - { - return null; - } - - if (globalSettings.SelfHosted) - { - return OrganizationMetadata.Default; - } - - var orgOccupiedSeats = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); - - if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) - { - return OrganizationMetadata.Default with - { - OrganizationOccupiedSeats = orgOccupiedSeats.Total - }; - } - - var customer = await subscriberService.GetCustomer(organization, - new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] }); - - var subscription = await subscriberService.GetSubscription(organization); - - if (customer == null || subscription == null) - { - return OrganizationMetadata.Default with - { - OrganizationOccupiedSeats = orgOccupiedSeats.Total - }; - } - - var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription); - - return new OrganizationMetadata( - isOnSecretsManagerStandalone, - orgOccupiedSeats.Total); - } - - private async Task IsOnSecretsManagerStandalone( - Organization organization, - Customer? customer, - Subscription? subscription) - { - if (customer == null || subscription == null) - { - return false; - } - - var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); - - if (!plan.SupportsSecretsManager) - { - return false; - } - - var hasCoupon = customer.Discount?.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone; - - if (!hasCoupon) - { - return false; - } - - var subscriptionProductIds = subscription.Items.Data.Select(item => item.Plan.ProductId); - - var couponAppliesTo = customer.Discount?.Coupon?.AppliesTo?.Products; - - return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); - } -} diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 494a202c4b..36a618f799 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -74,12 +74,16 @@ public class OrganizationBillingService( return OrganizationMetadata.Default; } - var orgOccupiedSeats = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var isEligibleForSelfHost = await IsEligibleForSelfHostAsync(organization); + var isManaged = organization.Status == OrganizationStatusType.Managed; + var orgOccupiedSeats = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { return OrganizationMetadata.Default with { + IsEligibleForSelfHost = isEligibleForSelfHost, + IsManaged = isManaged, OrganizationOccupiedSeats = orgOccupiedSeats.Total }; } @@ -93,14 +97,28 @@ public class OrganizationBillingService( { return OrganizationMetadata.Default with { - OrganizationOccupiedSeats = orgOccupiedSeats.Total + IsEligibleForSelfHost = isEligibleForSelfHost, + IsManaged = isManaged }; } var isOnSecretsManagerStandalone = await IsOnSecretsManagerStandalone(organization, customer, subscription); + var invoice = !string.IsNullOrEmpty(subscription.LatestInvoiceId) + ? await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions()) + : null; + return new OrganizationMetadata( + isEligibleForSelfHost, + isManaged, isOnSecretsManagerStandalone, + subscription.Status == StripeConstants.SubscriptionStatus.Unpaid, + true, + invoice?.Status == StripeConstants.InvoiceStatus.Open, + subscription.Status == StripeConstants.SubscriptionStatus.Canceled, + invoice?.DueDate, + invoice?.Created, + subscription.CurrentPeriodEnd, orgOccupiedSeats.Total); } @@ -518,6 +536,16 @@ public class OrganizationBillingService( return customer; } + private async Task IsEligibleForSelfHostAsync( + Organization organization) + { + var plans = await pricingClient.ListPlans(); + + var eligibleSelfHostPlans = plans.Where(plan => plan.HasSelfHost).Select(plan => plan.Type); + + return eligibleSelfHostPlans.Contains(organization.PlanType); + } + private async Task IsOnSecretsManagerStandalone( Organization organization, Customer? customer, diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index a3c637ecb1..80b74877c5 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -179,7 +179,6 @@ public static class FeatureFlagKeys public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings"; - public const string PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure"; public const string PM24996ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog"; public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button"; public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog"; diff --git a/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs index d79bfde893..51866320ee 100644 --- a/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/OrganizationBillingControllerTests.cs @@ -1,4 +1,5 @@ using Bit.Api.Billing.Controllers; +using Bit.Api.Billing.Models.Responses; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.Models; @@ -52,16 +53,19 @@ public class OrganizationBillingControllerTests { sutProvider.GetDependency().OrganizationUser(organizationId).Returns(true); sutProvider.GetDependency().GetMetadata(organizationId) - .Returns(new OrganizationMetadata(true, 10)); + .Returns(new OrganizationMetadata(true, true, true, true, true, true, true, null, null, null, 0)); var result = await sutProvider.Sut.GetMetadataAsync(organizationId); - Assert.IsType>(result); + Assert.IsType>(result); - var response = ((Ok)result).Value; + var response = ((Ok)result).Value; + Assert.True(response.IsEligibleForSelfHost); + Assert.True(response.IsManaged); Assert.True(response.IsOnSecretsManagerStandalone); - Assert.Equal(10, response.OrganizationOccupiedSeats); + Assert.True(response.IsSubscriptionUnpaid); + Assert.True(response.HasSubscription); } [Theory, BitAutoData] diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs deleted file mode 100644 index 21081112d7..0000000000 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs +++ /dev/null @@ -1,369 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Organizations.Models; -using Bit.Core.Billing.Organizations.Queries; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Services; -using Bit.Core.Models.Data.Organizations.OrganizationUsers; -using Bit.Core.Repositories; -using Bit.Core.Settings; -using Bit.Core.Utilities; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using NSubstitute.ReturnsExtensions; -using Stripe; -using Xunit; - -namespace Bit.Core.Test.Billing.Organizations.Queries; - -[SutProviderCustomize] -public class GetOrganizationMetadataQueryTests -{ - [Theory, BitAutoData] - public async Task Run_NullOrganization_ReturnsNull( - SutProvider sutProvider) - { - var result = await sutProvider.Sut.Run(null); - - Assert.Null(result); - } - - [Theory, BitAutoData] - public async Task Run_SelfHosted_ReturnsDefault( - Organization organization, - SutProvider sutProvider) - { - sutProvider.GetDependency().SelfHosted.Returns(true); - - var result = await sutProvider.Sut.Run(organization); - - Assert.Equal(OrganizationMetadata.Default, result); - } - - [Theory, BitAutoData] - public async Task Run_NoGatewaySubscriptionId_ReturnsDefaultWithOccupiedSeats( - Organization organization, - SutProvider sutProvider) - { - organization.GatewaySubscriptionId = null; - - sutProvider.GetDependency().SelfHosted.Returns(false); - sutProvider.GetDependency() - .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) - .Returns(new OrganizationSeatCounts { Users = 10, Sponsored = 0 }); - - var result = await sutProvider.Sut.Run(organization); - - Assert.NotNull(result); - Assert.False(result.IsOnSecretsManagerStandalone); - Assert.Equal(10, result.OrganizationOccupiedSeats); - } - - [Theory, BitAutoData] - public async Task Run_NullCustomer_ReturnsDefaultWithOccupiedSeats( - Organization organization, - SutProvider sutProvider) - { - organization.GatewaySubscriptionId = "sub_123"; - - sutProvider.GetDependency().SelfHosted.Returns(false); - sutProvider.GetDependency() - .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) - .Returns(new OrganizationSeatCounts { Users = 5, Sponsored = 0 }); - - sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) - .ReturnsNull(); - - var result = await sutProvider.Sut.Run(organization); - - Assert.NotNull(result); - Assert.False(result.IsOnSecretsManagerStandalone); - Assert.Equal(5, result.OrganizationOccupiedSeats); - } - - [Theory, BitAutoData] - public async Task Run_NullSubscription_ReturnsDefaultWithOccupiedSeats( - Organization organization, - SutProvider sutProvider) - { - organization.GatewaySubscriptionId = "sub_123"; - - var customer = new Customer(); - - sutProvider.GetDependency().SelfHosted.Returns(false); - sutProvider.GetDependency() - .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) - .Returns(new OrganizationSeatCounts { Users = 7, Sponsored = 0 }); - - sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) - .Returns(customer); - - sutProvider.GetDependency() - .GetSubscription(organization) - .ReturnsNull(); - - var result = await sutProvider.Sut.Run(organization); - - Assert.NotNull(result); - Assert.False(result.IsOnSecretsManagerStandalone); - Assert.Equal(7, result.OrganizationOccupiedSeats); - } - - [Theory, BitAutoData] - public async Task Run_WithSecretsManagerStandaloneCoupon_ReturnsMetadataWithFlag( - Organization organization, - SutProvider sutProvider) - { - organization.GatewaySubscriptionId = "sub_123"; - organization.PlanType = PlanType.EnterpriseAnnually; - - var productId = "product_123"; - var customer = new Customer - { - Discount = new Discount - { - Coupon = new Coupon - { - Id = StripeConstants.CouponIDs.SecretsManagerStandalone, - AppliesTo = new CouponAppliesTo - { - Products = [productId] - } - } - } - }; - - var subscription = new Subscription - { - Items = new StripeList - { - Data = - [ - new SubscriptionItem - { - Plan = new Plan - { - ProductId = productId - } - } - ] - } - }; - - sutProvider.GetDependency().SelfHosted.Returns(false); - sutProvider.GetDependency() - .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) - .Returns(new OrganizationSeatCounts { Users = 15, Sponsored = 0 }); - - sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) - .Returns(customer); - - sutProvider.GetDependency() - .GetSubscription(organization) - .Returns(subscription); - - sutProvider.GetDependency() - .GetPlanOrThrow(organization.PlanType) - .Returns(StaticStore.GetPlan(organization.PlanType)); - - var result = await sutProvider.Sut.Run(organization); - - Assert.NotNull(result); - Assert.True(result.IsOnSecretsManagerStandalone); - Assert.Equal(15, result.OrganizationOccupiedSeats); - } - - [Theory, BitAutoData] - public async Task Run_WithoutSecretsManagerStandaloneCoupon_ReturnsMetadataWithoutFlag( - Organization organization, - SutProvider sutProvider) - { - organization.GatewaySubscriptionId = "sub_123"; - organization.PlanType = PlanType.TeamsAnnually; - - var customer = new Customer - { - Discount = null - }; - - var subscription = new Subscription - { - Items = new StripeList - { - Data = - [ - new SubscriptionItem - { - Plan = new Plan - { - ProductId = "product_123" - } - } - ] - } - }; - - sutProvider.GetDependency().SelfHosted.Returns(false); - sutProvider.GetDependency() - .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) - .Returns(new OrganizationSeatCounts { Users = 20, Sponsored = 0 }); - - sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) - .Returns(customer); - - sutProvider.GetDependency() - .GetSubscription(organization) - .Returns(subscription); - - sutProvider.GetDependency() - .GetPlanOrThrow(organization.PlanType) - .Returns(StaticStore.GetPlan(organization.PlanType)); - - var result = await sutProvider.Sut.Run(organization); - - Assert.NotNull(result); - Assert.False(result.IsOnSecretsManagerStandalone); - Assert.Equal(20, result.OrganizationOccupiedSeats); - } - - [Theory, BitAutoData] - public async Task Run_CouponDoesNotApplyToSubscriptionProducts_ReturnsFalseForStandaloneFlag( - Organization organization, - SutProvider sutProvider) - { - organization.GatewaySubscriptionId = "sub_123"; - organization.PlanType = PlanType.EnterpriseAnnually; - - var customer = new Customer - { - Discount = new Discount - { - Coupon = new Coupon - { - Id = StripeConstants.CouponIDs.SecretsManagerStandalone, - AppliesTo = new CouponAppliesTo - { - Products = ["different_product_id"] - } - } - } - }; - - var subscription = new Subscription - { - Items = new StripeList - { - Data = - [ - new SubscriptionItem - { - Plan = new Plan - { - ProductId = "product_123" - } - } - ] - } - }; - - sutProvider.GetDependency().SelfHosted.Returns(false); - sutProvider.GetDependency() - .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) - .Returns(new OrganizationSeatCounts { Users = 12, Sponsored = 0 }); - - sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) - .Returns(customer); - - sutProvider.GetDependency() - .GetSubscription(organization) - .Returns(subscription); - - sutProvider.GetDependency() - .GetPlanOrThrow(organization.PlanType) - .Returns(StaticStore.GetPlan(organization.PlanType)); - - var result = await sutProvider.Sut.Run(organization); - - Assert.NotNull(result); - Assert.False(result.IsOnSecretsManagerStandalone); - Assert.Equal(12, result.OrganizationOccupiedSeats); - } - - [Theory, BitAutoData] - public async Task Run_PlanDoesNotSupportSecretsManager_ReturnsFalseForStandaloneFlag( - Organization organization, - SutProvider sutProvider) - { - organization.GatewaySubscriptionId = "sub_123"; - organization.PlanType = PlanType.FamiliesAnnually; - - var productId = "product_123"; - var customer = new Customer - { - Discount = new Discount - { - Coupon = new Coupon - { - Id = StripeConstants.CouponIDs.SecretsManagerStandalone, - AppliesTo = new CouponAppliesTo - { - Products = [productId] - } - } - } - }; - - var subscription = new Subscription - { - Items = new StripeList - { - Data = - [ - new SubscriptionItem - { - Plan = new Plan - { - ProductId = productId - } - } - ] - } - }; - - sutProvider.GetDependency().SelfHosted.Returns(false); - sutProvider.GetDependency() - .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) - .Returns(new OrganizationSeatCounts { Users = 8, Sponsored = 0 }); - - sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) - .Returns(customer); - - sutProvider.GetDependency() - .GetSubscription(organization) - .Returns(subscription); - - sutProvider.GetDependency() - .GetPlanOrThrow(organization.PlanType) - .Returns(StaticStore.GetPlan(organization.PlanType)); - - var result = await sutProvider.Sut.Run(organization); - - Assert.NotNull(result); - Assert.False(result.IsOnSecretsManagerStandalone); - Assert.Equal(8, result.OrganizationOccupiedSeats); - } -} diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index 77dce8101c..7edc60a26a 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -96,10 +96,6 @@ public class OrganizationBillingServiceTests sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) .Returns(StaticStore.GetPlan(organization.PlanType)); - sutProvider.GetDependency() - .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) - .Returns(new OrganizationSeatCounts { Users = 1, Sponsored = 0 }); - var subscriberService = sutProvider.GetDependency(); // Set up subscriber service to return null for customer @@ -114,7 +110,13 @@ public class OrganizationBillingServiceTests Assert.NotNull(metadata); Assert.False(metadata!.IsOnSecretsManagerStandalone); - Assert.Equal(1, metadata.OrganizationOccupiedSeats); + Assert.False(metadata.HasSubscription); + Assert.False(metadata.IsSubscriptionUnpaid); + Assert.False(metadata.HasOpenInvoice); + Assert.False(metadata.IsSubscriptionCanceled); + Assert.Null(metadata.InvoiceDueDate); + Assert.Null(metadata.InvoiceCreatedDate); + Assert.Null(metadata.SubPeriodEndDate); } #endregion From a565fd9ee4b2d3b86679c270222889ecc0944b29 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Fri, 10 Oct 2025 10:39:31 -0400 Subject: [PATCH 07/14] Add Microsoft Teams integration (#6410) * Add Microsoft Teams integration * Fix method naming error * Expand and clean up unit test coverage * Update with PR feedback * Add documentation, add In Progress logic/tests for Teams * Fixed lowercase Slack * Added docs; Updated PR suggestions; * Fix broken tests --- dev/servicebusemulator_config.json | 33 +- .../Controllers/SlackIntegrationController.cs | 6 +- .../Controllers/TeamsIntegrationController.cs | 147 +++++++ ...ionIntegrationConfigurationRequestModel.cs | 4 + .../OrgnizationIntegrationRequestModel.cs | 2 +- .../OrganizationIntegrationResponseModel.cs | 14 +- src/Api/Startup.cs | 3 +- .../AdminConsole/Enums/IntegrationType.cs | 5 +- .../EventIntegrations/TeamsIntegration.cs | 12 + .../TeamsIntegrationConfigurationDetails.cs | 3 + .../TeamsListenerConfiguration.cs | 38 ++ .../Models/Slack/SlackApiResponse.cs | 4 +- .../Models/Teams/TeamsApiResponse.cs | 41 ++ .../Teams/TeamsBotCredentialProvider.cs | 28 ++ .../IOrganizationIntegrationRepository.cs | 2 + .../AdminConsole/Services/ISlackService.cs | 48 +++ .../AdminConsole/Services/ITeamsService.cs | 49 +++ .../EventIntegrations/README.md | 53 ++- .../EventIntegrations/SlackService.cs | 6 + .../TeamsIntegrationHandler.cs | 41 ++ .../EventIntegrations/TeamsService.cs | 182 ++++++++ .../NoopImplementations/NoopTeamsService.cs | 27 ++ src/Core/Core.csproj | 2 + src/Core/Settings/GlobalSettings.cs | 15 + .../OrganizationIntegrationRepository.cs | 13 + .../OrganizationIntegrationRepository.cs | 12 + ...ByTeamsConfigurationTenantIdTeamIdQuery.cs | 36 ++ src/SharedWeb/SharedWeb.csproj | 1 + .../Utilities/ServiceCollectionExtensions.cs | 35 ++ ...ReadByTeamsConfigurationTenantIdTeamId.sql | 17 + .../SlackIntegrationControllerTests.cs | 30 +- .../TeamsIntegrationControllerTests.cs | 392 ++++++++++++++++++ ...tegrationConfigurationRequestModelTests.cs | 36 +- ...rganizationIntegrationRequestModelTests.cs | 16 + ...ganizationIntegrationResponseModelTests.cs | 43 ++ .../Teams/TeamsBotCredentialProviderTests.cs | 56 +++ .../Services/IntegrationTypeTests.cs | 41 +- .../Services/SlackServiceTests.cs | 12 + .../Services/TeamsIntegrationHandlerTests.cs | 126 ++++++ .../Services/TeamsServiceTests.cs | 289 +++++++++++++ ...igurationTenantIdTeamIdStoredProcedure.sql | 18 + 41 files changed, 1839 insertions(+), 99 deletions(-) create mode 100644 src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs create mode 100644 src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegration.cs create mode 100644 src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegrationConfigurationDetails.cs create mode 100644 src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsListenerConfiguration.cs create mode 100644 src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs create mode 100644 src/Core/AdminConsole/Models/Teams/TeamsBotCredentialProvider.cs create mode 100644 src/Core/AdminConsole/Services/ITeamsService.cs create mode 100644 src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs create mode 100644 src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsService.cs create mode 100644 src/Core/AdminConsole/Services/NoopImplementations/NoopTeamsService.cs create mode 100644 src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery.cs create mode 100644 src/Sql/dbo/Stored Procedures/OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId.sql create mode 100644 test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs create mode 100644 test/Core.Test/AdminConsole/Models/Data/Teams/TeamsBotCredentialProviderTests.cs create mode 100644 test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs create mode 100644 test/Core.Test/AdminConsole/Services/TeamsServiceTests.cs create mode 100644 util/Migrator/DbScripts/2025-10-3_00_AddOrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamIdStoredProcedure.sql diff --git a/dev/servicebusemulator_config.json b/dev/servicebusemulator_config.json index 294efc1897..bb50c0b1ee 100644 --- a/dev/servicebusemulator_config.json +++ b/dev/servicebusemulator_config.json @@ -3,22 +3,6 @@ "Namespaces": [ { "Name": "sbemulatorns", - "Queues": [ - { - "Name": "queue.1", - "Properties": { - "DeadLetteringOnMessageExpiration": false, - "DefaultMessageTimeToLive": "PT1H", - "DuplicateDetectionHistoryTimeWindow": "PT20S", - "ForwardDeadLetteredMessagesTo": "", - "ForwardTo": "", - "LockDuration": "PT1M", - "MaxDeliveryCount": 3, - "RequiresDuplicateDetection": false, - "RequiresSession": false - } - } - ], "Topics": [ { "Name": "event-logging", @@ -37,6 +21,9 @@ }, { "Name": "events-datadog-subscription" + }, + { + "Name": "events-teams-subscription" } ] }, @@ -98,6 +85,20 @@ } } ] + }, + { + "Name": "integration-teams-subscription", + "Rules": [ + { + "Name": "teams-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "teams" + } + } + } + ] } ] } diff --git a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs index c8ff4f9f7c..08635878de 100644 --- a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs @@ -32,7 +32,7 @@ public class SlackIntegrationController( } string? callbackUrl = Url.RouteUrl( - routeName: nameof(CreateAsync), + routeName: "SlackIntegration_Create", values: null, protocol: currentContext.HttpContext.Request.Scheme, host: currentContext.HttpContext.Request.Host.ToUriComponent() @@ -76,7 +76,7 @@ public class SlackIntegrationController( return Redirect(redirectUrl); } - [HttpGet("integrations/slack/create", Name = nameof(CreateAsync))] + [HttpGet("integrations/slack/create", Name = "SlackIntegration_Create")] [AllowAnonymous] public async Task CreateAsync([FromQuery] string code, [FromQuery] string state) { @@ -103,7 +103,7 @@ public class SlackIntegrationController( // Fetch token from Slack and store to DB string? callbackUrl = Url.RouteUrl( - routeName: nameof(CreateAsync), + routeName: "SlackIntegration_Create", values: null, protocol: currentContext.HttpContext.Request.Scheme, host: currentContext.HttpContext.Request.Host.ToUriComponent() diff --git a/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs b/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs new file mode 100644 index 0000000000..8cafb6b2cf --- /dev/null +++ b/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs @@ -0,0 +1,147 @@ +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; + +namespace Bit.Api.AdminConsole.Controllers; + +[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] +[Route("organizations")] +[Authorize("Application")] +public class TeamsIntegrationController( + ICurrentContext currentContext, + IOrganizationIntegrationRepository integrationRepository, + IBot bot, + IBotFrameworkHttpAdapter adapter, + ITeamsService teamsService, + TimeProvider timeProvider) : Controller +{ + [HttpGet("{organizationId:guid}/integrations/teams/redirect")] + public async Task RedirectAsync(Guid organizationId) + { + if (!await currentContext.OrganizationOwner(organizationId)) + { + throw new NotFoundException(); + } + + var callbackUrl = Url.RouteUrl( + routeName: "TeamsIntegration_Create", + values: null, + protocol: currentContext.HttpContext.Request.Scheme, + host: currentContext.HttpContext.Request.Host.ToUriComponent() + ); + if (string.IsNullOrEmpty(callbackUrl)) + { + throw new BadRequestException("Unable to build callback Url"); + } + + var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId); + var integration = integrations.FirstOrDefault(i => i.Type == IntegrationType.Teams); + + if (integration is null) + { + // No teams integration exists, create Initiated version + integration = await integrationRepository.CreateAsync(new OrganizationIntegration + { + OrganizationId = organizationId, + Type = IntegrationType.Teams, + Configuration = null, + }); + } + else if (integration.Configuration is not null) + { + // A Completed (fully configured) Teams integration already exists, throw to prevent overriding + throw new BadRequestException("There already exists a Teams integration for this organization"); + + } // An Initiated teams integration exits, re-use it and kick off a new OAuth flow + + var state = IntegrationOAuthState.FromIntegration(integration, timeProvider); + var redirectUrl = teamsService.GetRedirectUrl( + callbackUrl: callbackUrl, + state: state.ToString() + ); + + if (string.IsNullOrEmpty(redirectUrl)) + { + throw new NotFoundException(); + } + + return Redirect(redirectUrl); + } + + [HttpGet("integrations/teams/create", Name = "TeamsIntegration_Create")] + [AllowAnonymous] + public async Task CreateAsync([FromQuery] string code, [FromQuery] string state) + { + var oAuthState = IntegrationOAuthState.FromString(state: state, timeProvider: timeProvider); + if (oAuthState is null) + { + throw new NotFoundException(); + } + + // Fetch existing Initiated record + var integration = await integrationRepository.GetByIdAsync(oAuthState.IntegrationId); + if (integration is null || + integration.Type != IntegrationType.Teams || + integration.Configuration is not null) + { + throw new NotFoundException(); + } + + // Verify Organization matches hash + if (!oAuthState.ValidateOrg(integration.OrganizationId)) + { + throw new NotFoundException(); + } + + var callbackUrl = Url.RouteUrl( + routeName: "TeamsIntegration_Create", + values: null, + protocol: currentContext.HttpContext.Request.Scheme, + host: currentContext.HttpContext.Request.Host.ToUriComponent() + ); + if (string.IsNullOrEmpty(callbackUrl)) + { + throw new BadRequestException("Unable to build callback Url"); + } + + var token = await teamsService.ObtainTokenViaOAuth(code, callbackUrl); + if (string.IsNullOrEmpty(token)) + { + throw new BadRequestException("Invalid response from Teams."); + } + + var teams = await teamsService.GetJoinedTeamsAsync(token); + + if (!teams.Any()) + { + throw new BadRequestException("No teams were found."); + } + + var teamsIntegration = new TeamsIntegration(TenantId: teams[0].TenantId, Teams: teams); + integration.Configuration = JsonSerializer.Serialize(teamsIntegration); + await integrationRepository.UpsertAsync(integration); + + var location = $"/organizations/{integration.OrganizationId}/integrations/{integration.Id}"; + return Created(location, new OrganizationIntegrationResponseModel(integration)); + } + + [Route("integrations/teams/incoming")] + [AllowAnonymous] + [HttpPost] + public async Task IncomingPostAsync() + { + await adapter.ProcessAsync(Request, Response, bot); + } +} diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs index 7d1efe2315..8581c4ae1f 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs @@ -38,6 +38,10 @@ public class OrganizationIntegrationConfigurationRequestModel return !string.IsNullOrWhiteSpace(Template) && Configuration is null && IsFiltersValid(); + case IntegrationType.Teams: + return !string.IsNullOrWhiteSpace(Template) && + Configuration is null && + IsFiltersValid(); default: return false; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs index 92d65ab8fe..668afe70bf 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs @@ -35,7 +35,7 @@ public class OrganizationIntegrationRequestModel : IValidatableObject case IntegrationType.CloudBillingSync or IntegrationType.Scim: yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", [nameof(Type)]); break; - case IntegrationType.Slack: + case IntegrationType.Slack or IntegrationType.Teams: yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", [nameof(Type)]); break; case IntegrationType.Webhook: diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs index 5368f78e39..0c31e07bef 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs @@ -1,4 +1,6 @@ -using Bit.Core.AdminConsole.Entities; +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; using Bit.Core.Models.Api; @@ -35,6 +37,16 @@ public class OrganizationIntegrationResponseModel : ResponseModel ? OrganizationIntegrationStatus.Initiated : OrganizationIntegrationStatus.Completed, + // If present and the configuration is null, OAuth has been initiated, and we are + // waiting on the return OAuth call. If Configuration is not null and IsCompleted is true, + // then we've received the app install bot callback, and it's Completed. Otherwise, + // it is In Progress while we await the app install bot callback. + IntegrationType.Teams => string.IsNullOrWhiteSpace(Configuration) + ? OrganizationIntegrationStatus.Initiated + : (JsonSerializer.Deserialize(Configuration)?.IsCompleted ?? false) + ? OrganizationIntegrationStatus.Completed + : OrganizationIntegrationStatus.InProgress, + // HEC and Datadog should only be allowed to be created non-null. // If they are null, they are Invalid IntegrationType.Hec => string.IsNullOrWhiteSpace(Configuration) diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index cc50a1b362..5d9918d1d4 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -229,8 +229,9 @@ public class Startup services.AddHostedService(); } - // Add SlackService for OAuth API requests - if configured + // Add Slack / Teams Services for OAuth API requests - if configured services.AddSlackService(globalSettings); + services.AddTeamsService(globalSettings); } public void Configure( diff --git a/src/Core/AdminConsole/Enums/IntegrationType.cs b/src/Core/AdminConsole/Enums/IntegrationType.cs index 34edc71fbe..84e4de94e9 100644 --- a/src/Core/AdminConsole/Enums/IntegrationType.cs +++ b/src/Core/AdminConsole/Enums/IntegrationType.cs @@ -7,7 +7,8 @@ public enum IntegrationType : int Slack = 3, Webhook = 4, Hec = 5, - Datadog = 6 + Datadog = 6, + Teams = 7 } public static class IntegrationTypeExtensions @@ -24,6 +25,8 @@ public static class IntegrationTypeExtensions return "hec"; case IntegrationType.Datadog: return "datadog"; + case IntegrationType.Teams: + return "teams"; default: throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}"); } diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegration.cs new file mode 100644 index 0000000000..8390022839 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegration.cs @@ -0,0 +1,12 @@ +using Bit.Core.Models.Teams; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record TeamsIntegration( + string TenantId, + IReadOnlyList Teams, + string? ChannelId = null, + Uri? ServiceUrl = null) +{ + public bool IsCompleted => !string.IsNullOrEmpty(ChannelId) && ServiceUrl is not null; +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegrationConfigurationDetails.cs new file mode 100644 index 0000000000..66fe558dff --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsIntegrationConfigurationDetails.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record TeamsIntegrationConfigurationDetails(string ChannelId, Uri ServiceUrl); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsListenerConfiguration.cs new file mode 100644 index 0000000000..24cf674648 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/TeamsListenerConfiguration.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class TeamsListenerConfiguration(GlobalSettings globalSettings) : + ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration +{ + public IntegrationType IntegrationType + { + get => IntegrationType.Teams; + } + + public string EventQueueName + { + get => _globalSettings.EventLogging.RabbitMq.TeamsEventsQueueName; + } + + public string IntegrationQueueName + { + get => _globalSettings.EventLogging.RabbitMq.TeamsIntegrationQueueName; + } + + public string IntegrationRetryQueueName + { + get => _globalSettings.EventLogging.RabbitMq.TeamsIntegrationRetryQueueName; + } + + public string EventSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.TeamsEventSubscriptionName; + } + + public string IntegrationSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.TeamsIntegrationSubscriptionName; + } +} diff --git a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs index ede2123f7e..70d280c428 100644 --- a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs +++ b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace Bit.Core.Models.Slack; diff --git a/src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs b/src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs new file mode 100644 index 0000000000..131e45264f --- /dev/null +++ b/src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; + +namespace Bit.Core.Models.Teams; + +/// Represents the response returned by the Microsoft OAuth 2.0 token endpoint. +/// See Microsoft identity platform and OAuth 2.0 +/// authorization code flow. +public class TeamsOAuthResponse +{ + /// The access token issued by Microsoft, used to call the Microsoft Graph API. + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; +} + +/// Represents the response from the /me/joinedTeams Microsoft Graph API call. +/// See List joined teams - +/// Microsoft Graph v1.0. +public class JoinedTeamsResponse +{ + /// The collection of teams that the user has joined. + [JsonPropertyName("value")] + public List Value { get; set; } = []; +} + +/// Represents a Microsoft Teams team returned by the Graph API. +/// See Team resource type - +/// Microsoft Graph v1.0. +public class TeamInfo +{ + /// The unique identifier of the team. + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// The name of the team. + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + /// The ID of the Microsoft Entra tenant for this team. + [JsonPropertyName("tenantId")] + public string TenantId { get; set; } = string.Empty; +} diff --git a/src/Core/AdminConsole/Models/Teams/TeamsBotCredentialProvider.cs b/src/Core/AdminConsole/Models/Teams/TeamsBotCredentialProvider.cs new file mode 100644 index 0000000000..eeb17131a3 --- /dev/null +++ b/src/Core/AdminConsole/Models/Teams/TeamsBotCredentialProvider.cs @@ -0,0 +1,28 @@ +using Microsoft.Bot.Connector.Authentication; + +namespace Bit.Core.AdminConsole.Models.Teams; + +public class TeamsBotCredentialProvider(string clientId, string clientSecret) : ICredentialProvider +{ + private const string _microsoftBotFrameworkIssuer = AuthenticationConstants.ToBotFromChannelTokenIssuer; + + public Task IsValidAppIdAsync(string appId) + { + return Task.FromResult(appId == clientId); + } + + public Task GetAppPasswordAsync(string appId) + { + return Task.FromResult(appId == clientId ? clientSecret : null); + } + + public Task IsAuthenticationDisabledAsync() + { + return Task.FromResult(false); + } + + public Task ValidateIssuerAsync(string issuer) + { + return Task.FromResult(issuer == _microsoftBotFrameworkIssuer); + } +} diff --git a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs index 434c8ddee3..1d8b8be0ec 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationRepository.cs @@ -5,4 +5,6 @@ namespace Bit.Core.Repositories; public interface IOrganizationIntegrationRepository : IRepository { Task> GetManyByOrganizationAsync(Guid organizationId); + + Task GetByTeamsConfigurationTenantIdTeamId(string tenantId, string teamId); } diff --git a/src/Core/AdminConsole/Services/ISlackService.cs b/src/Core/AdminConsole/Services/ISlackService.cs index ff1e03f051..0577532ac2 100644 --- a/src/Core/AdminConsole/Services/ISlackService.cs +++ b/src/Core/AdminConsole/Services/ISlackService.cs @@ -1,11 +1,59 @@ namespace Bit.Core.Services; +/// Defines operations for interacting with Slack, including OAuth authentication, channel discovery, +/// and sending messages. public interface ISlackService { + /// Note: This API is not currently used (yet) by any server code. It is here to provide functionality if + /// the UI needs to be able to look up channels for a user. + /// Retrieves the ID of a Slack channel by name. + /// See conversations.list API. + /// A valid Slack OAuth access token. + /// The name of the channel to look up. + /// The channel ID if found; otherwise, an empty string. Task GetChannelIdAsync(string token, string channelName); + + /// Note: This API is not currently used (yet) by any server code. It is here to provide functionality if + /// the UI needs to be able to look up channels for a user. + /// Retrieves the IDs of multiple Slack channels by name. + /// See conversations.list API. + /// A valid Slack OAuth access token. + /// A list of channel names to look up. + /// A list of matching channel IDs. Channels that cannot be found are omitted. Task> GetChannelIdsAsync(string token, List channelNames); + + /// Note: This API is not currently used (yet) by any server code. It is here to provide functionality if + /// the UI needs to be able to look up a user by their email address. + /// Retrieves the DM channel ID for a Slack user by email. + /// See users.lookupByEmail API and + /// conversations.open API. + /// A valid Slack OAuth access token. + /// The email address of the user to open a DM with. + /// The DM channel ID if successful; otherwise, an empty string. Task GetDmChannelByEmailAsync(string token, string email); + + /// Builds the Slack OAuth 2.0 authorization URL for the app. + /// See Slack OAuth v2 documentation. + /// The absolute redirect URI that Slack will call after user authorization. + /// Must match the URI registered with the app configuration. + /// A state token used to correlate the request and callback and prevent CSRF attacks. + /// The full authorization URL to which the user should be redirected to begin the sign-in process. string GetRedirectUrl(string callbackUrl, string state); + + /// Exchanges a Slack OAuth code for an access token. + /// See oauth.v2.access API. + /// The authorization code returned by Slack via the callback URL after user authorization. + /// The redirect URI that was used in the authorization request. + /// A valid Slack access token if successful; otherwise, an empty string. Task ObtainTokenViaOAuth(string code, string redirectUrl); + + /// Sends a message to a Slack channel by ID. + /// See chat.postMessage API. + /// This is used primarily by the to send events to the + /// Slack channel. + /// A valid Slack OAuth access token. + /// The message text to send. + /// The channel ID to send the message to. + /// A task that completes when the message has been sent. Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId); } diff --git a/src/Core/AdminConsole/Services/ITeamsService.cs b/src/Core/AdminConsole/Services/ITeamsService.cs new file mode 100644 index 0000000000..e3757987c3 --- /dev/null +++ b/src/Core/AdminConsole/Services/ITeamsService.cs @@ -0,0 +1,49 @@ +using Bit.Core.Models.Teams; + +namespace Bit.Core.Services; + +/// +/// Service that provides functionality relating to the Microsoft Teams integration including OAuth, +/// team discovery and sending a message to a channel in Teams. +/// +public interface ITeamsService +{ + /// + /// Generate the Microsoft Teams OAuth 2.0 authorization URL used to begin the sign-in flow. + /// + /// The absolute redirect URI that Microsoft will call after user authorization. + /// Must match the URI registered with the app configuration. + /// A state token used to correlate the request and callback and prevent CSRF attacks. + /// The full authorization URL to which the user should be redirected to begin the sign-in process. + string GetRedirectUrl(string callbackUrl, string state); + + /// + /// Exchange the OAuth code for a Microsoft Graph API access token. + /// + /// The code returned from Microsoft via the OAuth callback Url. + /// The same redirect URI that was passed to the authorization request. + /// A valid Microsoft Graph access token if the exchange succeeds; otherwise, an empty string. + Task ObtainTokenViaOAuth(string code, string redirectUrl); + + /// + /// Get the Teams to which the authenticated user belongs via Microsoft Graph API. + /// + /// A valid Microsoft Graph access token for the user (obtained via OAuth). + /// A read-only list of objects representing the user’s joined teams. + /// Returns an empty list if the request fails or if the token is invalid. + Task> GetJoinedTeamsAsync(string accessToken); + + /// + /// Send a message to a specific channel in Teams. + /// + /// This is used primarily by the to send events to the + /// Teams channel. + /// The service URI associated with the Microsoft Bot Framework connector for the target + /// team. Obtained via the bot framework callback. + /// The conversation or channel ID where the message should be delivered. Obtained via + /// the bot framework callback. + /// The message text to post to the channel. + /// A task that completes when the message has been sent. Errors during message delivery are surfaced + /// as exceptions from the underlying connector client. + Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message); +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md index de7ce3f7fd..7570d47211 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md @@ -203,31 +203,17 @@ Currently, there are integrations / handlers for Slack, webhooks, and HTTP Event - The top-level object that enables a specific integration for the organization. - Includes any properties that apply to the entire integration across all events. - - For Slack, it consists of the token: `{ "Token": "xoxb-token-from-slack" }`. - - For webhooks, it is optional. Webhooks can either be configured at this level or the configuration level, - but the configuration level takes precedence. However, even though it is optional, an organization must - have a webhook `OrganizationIntegration` (even will a `null` `Configuration`) to enable configuration - via `OrganizationIntegrationConfiguration`. - - For HEC, it consists of the scheme, token, and URI: - -```json - { - "Scheme": "Bearer", - "Token": "Auth-token-from-HEC-service", - "Uri": "https://example.com/api" - } -``` + - For example, Slack stores the token in the `Configuration` which applies to every event, but stores the +channel id in the `Configuration` of the `OrganizationIntegrationConfiguration`. The token applies to the entire Slack +integration, but the channel could be configured differently depending on event type. + - See the table below for more examples / details on what is stored at which level. ### `OrganizationIntegrationConfiguration` - This contains the configurations specific to each `EventType` for the integration. - `Configuration` contains the event-specific configuration. - - For Slack, this would contain what channel to send the message to: `{ "channelId": "C123456" }` - - For webhooks, this is the URL the request should be sent to: `{ "url": "https://api.example.com" }` - - Optionally this also can include a `Scheme` and `Token` if this webhook needs Authentication. - - As stated above, all of this information can be specified here or at the `OrganizationIntegration` - level, but any properties declared here will take precedence over the ones above. - - For HEC, this must be null. HEC is configured only at the `OrganizationIntegration` level. + - Any properties at this level override the `Configuration` form the `OrganizationIntegration`. + - See the table below for examples of specific integrations. - `Template` contains a template string that is expected to be filled in with the contents of the actual event. - The tokens in the string are wrapped in `#` characters. For instance, the UserId would be `#UserId#`. - The `IntegrationTemplateProcessor` does the actual work of replacing these tokens with introspected values from @@ -245,6 +231,23 @@ Currently, there are integrations / handlers for Slack, webhooks, and HTTP Event - An array of `OrganizationIntegrationConfigurationDetails` is what the `EventIntegrationHandler` fetches from the database to determine what to publish at the integration level. +### Existing integrations and the configurations at each level + +The following table illustrates how each integration is configured and what exactly is stored in the `Configuration` +property at each level (`OrganizationIntegration` or `OrganizationIntegrationConfiguration`). Under +`OrganizationIntegration` the valid `OrganizationIntegrationStatus` are in bold, with an example of what would be +stored at each status. + +| **Integration** | **OrganizationIntegration** | **OrganizationIntegrationConfiguration** | +|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| CloudBillingSync | **Not Applicable** (not yet used) | **Not Applicable** (not yet used) | +| Scim | **Not Applicable** (not yet used) | **Not Applicable** (not yet used) | +| Slack | **Initiated**: `null`
**Completed**:
`{ "Token": "xoxb-token-from-slack" }` | `{ "channelId": "C123456" }` | +| Webhook | `null` or `{ "Scheme": "Bearer", "Token": "AUTH-TOKEN", "Uri": "https://example.com" }` | `null` or `{ "Scheme": "Bearer", "Token":"AUTH-TOKEN", "Uri": "https://example.com" }`

Whatever is defined at this level takes precedence | +| Hec | `{ "Scheme": "Bearer", "Token": "AUTH-TOKEN", "Uri": "https://example.com" }` | Always `null` | +| Datadog | `{ "ApiKey": "TheKey12345", "Uri": "https://api.us5.datadoghq.com/api/v1/events"}` | Always `null` | +| Teams | **Initiated**: `null`
**In Progress**:
`{ "TenantID": "tenant", "Teams": ["Id": "team", DisplayName: "MyTeam"]}`
**Completed**:
`{ "TenantID": "tenant", "Teams": ["Id": "team", DisplayName: "MyTeam"], "ServiceUrl":"https://example.com", ChannelId: "channel-1234"}` | Always `null` | + ## Filtering In addition to the ability to configure integrations mentioned above, organization admins can @@ -349,10 +352,20 @@ and event type. - This will be the deserialized version of the `MergedConfiguration` in `OrganizationIntegrationConfigurationDetails`. +A new row with the new integration should be added to this doc in the table above [Existing integrations +and the configurations at each level](#existing-integrations-and-the-configurations-at-each-level). + ## Request Models 1. Add a new case to the switch method in `OrganizationIntegrationRequestModel.Validate`. + - Additionally, add tests in `OrganizationIntegrationRequestModelTests` 2. Add a new case to the switch method in `OrganizationIntegrationConfigurationRequestModel.IsValidForType`. + - Additionally, add / update tests in `OrganizationIntegrationConfigurationRequestModelTests` + +## Response Model + +1. Add a new case to the switch method in `OrganizationIntegrationResponseModel.Status`. + - Additionally, add / update tests in `OrganizationIntegrationResponseModelTests` ## Integration Handler diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs index 4fb74f1f44..8b691dd4bf 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs @@ -90,6 +90,12 @@ public class SlackService( public async Task ObtainTokenViaOAuth(string code, string redirectUrl) { + if (string.IsNullOrEmpty(code) || string.IsNullOrWhiteSpace(redirectUrl)) + { + logger.LogError("Error obtaining token via OAuth: Code and/or RedirectUrl were empty"); + return string.Empty; + } + var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access", new FormUrlEncodedContent(new[] { diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs new file mode 100644 index 0000000000..41d60bd69c --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsIntegrationHandler.cs @@ -0,0 +1,41 @@ +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Microsoft.Rest; + +namespace Bit.Core.Services; + +public class TeamsIntegrationHandler( + ITeamsService teamsService) + : IntegrationHandlerBase +{ + public override async Task HandleAsync( + IntegrationMessage message) + { + try + { + await teamsService.SendMessageToChannelAsync( + serviceUri: message.Configuration.ServiceUrl, + message: message.RenderedTemplate, + channelId: message.Configuration.ChannelId + ); + + return new IntegrationHandlerResult(success: true, message: message); + } + catch (HttpOperationException ex) + { + var result = new IntegrationHandlerResult(success: false, message: message); + var statusCode = (int)ex.Response.StatusCode; + result.Retryable = statusCode is 429 or >= 500 and < 600; + result.FailureReason = ex.Message; + + return result; + } + catch (Exception ex) + { + var result = new IntegrationHandlerResult(success: false, message: message); + result.Retryable = false; + result.FailureReason = ex.Message; + + return result; + } + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsService.cs new file mode 100644 index 0000000000..f9911760bb --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/TeamsService.cs @@ -0,0 +1,182 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Web; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Models.Teams; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Teams; +using Microsoft.Bot.Connector; +using Microsoft.Bot.Connector.Authentication; +using Microsoft.Bot.Schema; +using Microsoft.Extensions.Logging; +using TeamInfo = Bit.Core.Models.Teams.TeamInfo; + +namespace Bit.Core.Services; + +public class TeamsService( + IHttpClientFactory httpClientFactory, + IOrganizationIntegrationRepository integrationRepository, + GlobalSettings globalSettings, + ILogger logger) : ActivityHandler, ITeamsService +{ + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + private readonly string _clientId = globalSettings.Teams.ClientId; + private readonly string _clientSecret = globalSettings.Teams.ClientSecret; + private readonly string _scopes = globalSettings.Teams.Scopes; + private readonly string _graphBaseUrl = globalSettings.Teams.GraphBaseUrl; + private readonly string _loginBaseUrl = globalSettings.Teams.LoginBaseUrl; + + public const string HttpClientName = "TeamsServiceHttpClient"; + + public string GetRedirectUrl(string redirectUrl, string state) + { + var query = HttpUtility.ParseQueryString(string.Empty); + query["client_id"] = _clientId; + query["response_type"] = "code"; + query["redirect_uri"] = redirectUrl; + query["response_mode"] = "query"; + query["scope"] = string.Join(" ", _scopes); + query["state"] = state; + + return $"{_loginBaseUrl}/common/oauth2/v2.0/authorize?{query}"; + } + + public async Task ObtainTokenViaOAuth(string code, string redirectUrl) + { + if (string.IsNullOrEmpty(code) || string.IsNullOrWhiteSpace(redirectUrl)) + { + logger.LogError("Error obtaining token via OAuth: Code and/or RedirectUrl were empty"); + return string.Empty; + } + + var request = new HttpRequestMessage(HttpMethod.Post, + $"{_loginBaseUrl}/common/oauth2/v2.0/token"); + + request.Content = new FormUrlEncodedContent(new Dictionary + { + { "client_id", _clientId }, + { "client_secret", _clientSecret }, + { "code", code }, + { "redirect_uri", redirectUrl }, + { "grant_type", "authorization_code" } + }); + + using var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var errorText = await response.Content.ReadAsStringAsync(); + logger.LogError("Teams OAuth token exchange failed: {errorText}", errorText); + return string.Empty; + } + + TeamsOAuthResponse? result; + try + { + result = await response.Content.ReadFromJsonAsync(); + } + catch + { + result = null; + } + + if (result is null) + { + logger.LogError("Error obtaining token via OAuth: Unknown error"); + return string.Empty; + } + + return result.AccessToken; + } + + public async Task> GetJoinedTeamsAsync(string accessToken) + { + using var request = new HttpRequestMessage( + HttpMethod.Get, + $"{_graphBaseUrl}/me/joinedTeams"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var errorText = await response.Content.ReadAsStringAsync(); + logger.LogError("Get Teams request failed: {errorText}", errorText); + return new List(); + } + + var result = await response.Content.ReadFromJsonAsync(); + + return result?.Value ?? []; + } + + public async Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message) + { + var credentials = new MicrosoftAppCredentials(_clientId, _clientSecret); + using var connectorClient = new ConnectorClient(serviceUri, credentials); + + var activity = new Activity + { + Type = ActivityTypes.Message, + Text = message + }; + + await connectorClient.Conversations.SendToConversationAsync(channelId, activity); + } + + protected override async Task OnInstallationUpdateAddAsync(ITurnContext turnContext, + CancellationToken cancellationToken) + { + var conversationId = turnContext.Activity.Conversation.Id; + var serviceUrl = turnContext.Activity.ServiceUrl; + var teamId = turnContext.Activity.TeamsGetTeamInfo().AadGroupId; + var tenantId = turnContext.Activity.Conversation.TenantId; + + if (!string.IsNullOrWhiteSpace(conversationId) && + !string.IsNullOrWhiteSpace(serviceUrl) && + Uri.TryCreate(serviceUrl, UriKind.Absolute, out var parsedUri) && + !string.IsNullOrWhiteSpace(teamId) && + !string.IsNullOrWhiteSpace(tenantId)) + { + await HandleIncomingAppInstallAsync( + conversationId: conversationId, + serviceUrl: parsedUri, + teamId: teamId, + tenantId: tenantId + ); + } + + await base.OnInstallationUpdateAddAsync(turnContext, cancellationToken); + } + + internal async Task HandleIncomingAppInstallAsync( + string conversationId, + Uri serviceUrl, + string teamId, + string tenantId) + { + var integration = await integrationRepository.GetByTeamsConfigurationTenantIdTeamId( + tenantId: tenantId, + teamId: teamId); + + if (integration?.Configuration is null) + { + return; + } + + var teamsConfig = JsonSerializer.Deserialize(integration.Configuration); + if (teamsConfig is null || teamsConfig.IsCompleted) + { + return; + } + + integration.Configuration = JsonSerializer.Serialize(teamsConfig with + { + ChannelId = conversationId, + ServiceUrl = serviceUrl + }); + + await integrationRepository.UpsertAsync(integration); + } +} diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopTeamsService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopTeamsService.cs new file mode 100644 index 0000000000..fafb23f570 --- /dev/null +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopTeamsService.cs @@ -0,0 +1,27 @@ +using Bit.Core.Models.Teams; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.Services.NoopImplementations; + +public class NoopTeamsService : ITeamsService +{ + public string GetRedirectUrl(string callbackUrl, string state) + { + return string.Empty; + } + + public Task ObtainTokenViaOAuth(string code, string redirectUrl) + { + return Task.FromResult(string.Empty); + } + + public Task> GetJoinedTeamsAsync(string accessToken) + { + return Task.FromResult>(Array.Empty()); + } + + public Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message) + { + return Task.CompletedTask; + } +} diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index e9bf1b1807..23cb885bd4 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -38,6 +38,8 @@ + + diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 250daf0007..d79b7290ec 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -62,6 +62,7 @@ public class GlobalSettings : IGlobalSettings public virtual SqlSettings MySql { get; set; } = new SqlSettings(); public virtual SqlSettings Sqlite { get; set; } = new SqlSettings() { ConnectionString = "Data Source=:memory:" }; public virtual SlackSettings Slack { get; set; } = new SlackSettings(); + public virtual TeamsSettings Teams { get; set; } = new TeamsSettings(); public virtual EventLoggingSettings EventLogging { get; set; } = new EventLoggingSettings(); public virtual MailSettings Mail { get; set; } = new MailSettings(); public virtual IConnectionStringSettings Storage { get; set; } = new ConnectionStringSettings(); @@ -295,6 +296,15 @@ public class GlobalSettings : IGlobalSettings public virtual string Scopes { get; set; } } + public class TeamsSettings + { + public virtual string LoginBaseUrl { get; set; } = "https://login.microsoftonline.com"; + public virtual string GraphBaseUrl { get; set; } = "https://graph.microsoft.com/v1.0"; + public virtual string ClientId { get; set; } + public virtual string ClientSecret { get; set; } + public virtual string Scopes { get; set; } + } + public class EventLoggingSettings { public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings(); @@ -320,6 +330,8 @@ public class GlobalSettings : IGlobalSettings public virtual string HecIntegrationSubscriptionName { get; set; } = "integration-hec-subscription"; public virtual string DatadogEventSubscriptionName { get; set; } = "events-datadog-subscription"; public virtual string DatadogIntegrationSubscriptionName { get; set; } = "integration-datadog-subscription"; + public virtual string TeamsEventSubscriptionName { get; set; } = "events-teams-subscription"; + public virtual string TeamsIntegrationSubscriptionName { get; set; } = "integration-teams-subscription"; public string ConnectionString { @@ -364,6 +376,9 @@ public class GlobalSettings : IGlobalSettings public virtual string DatadogEventsQueueName { get; set; } = "events-datadog-queue"; public virtual string DatadogIntegrationQueueName { get; set; } = "integration-datadog-queue"; public virtual string DatadogIntegrationRetryQueueName { get; set; } = "integration-datadog-retry-queue"; + public virtual string TeamsEventsQueueName { get; set; } = "events-teams-queue"; + public virtual string TeamsIntegrationQueueName { get; set; } = "integration-teams-queue"; + public virtual string TeamsIntegrationRetryQueueName { get; set; } = "integration-teams-retry-queue"; public string HostName { diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationRepository.cs index ece9697a31..4f8fb979d3 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationIntegrationRepository.cs @@ -29,4 +29,17 @@ public class OrganizationIntegrationRepository : Repository GetByTeamsConfigurationTenantIdTeamId(string tenantId, string teamId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var result = await connection.QuerySingleOrDefaultAsync( + "[dbo].[OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId]", + new { TenantId = tenantId, TeamId = teamId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationRepository.cs index 5670b2ae9b..c11591efcd 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationIntegrationRepository.cs @@ -26,4 +26,16 @@ public class OrganizationIntegrationRepository : return await query.Run(dbContext).ToListAsync(); } } + + public async Task GetByTeamsConfigurationTenantIdTeamId( + string tenantId, + string teamId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var query = new OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery(tenantId: tenantId, teamId: teamId); + return await query.Run(dbContext).SingleOrDefaultAsync(); + } + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery.cs new file mode 100644 index 0000000000..a1e86d9add --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery.cs @@ -0,0 +1,36 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories.Queries; + +namespace Bit.Infrastructure.EntityFramework.AdminConsole.Repositories.Queries; + +public class OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery : IQuery +{ + private readonly string _tenantId; + private readonly string _teamId; + + public OrganizationIntegrationReadByTeamsConfigurationTenantIdTeamIdQuery(string tenantId, string teamId) + { + _tenantId = tenantId; + _teamId = teamId; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var query = + from oi in dbContext.OrganizationIntegrations + where oi.Type == IntegrationType.Teams && + oi.Configuration != null && + oi.Configuration.Contains($"\"TenantId\":\"{_tenantId}\"") && + oi.Configuration.Contains($"\"id\":\"{_teamId}\"") + select new OrganizationIntegration() + { + Id = oi.Id, + OrganizationId = oi.OrganizationId, + Type = oi.Type, + Configuration = oi.Configuration, + }; + return query; + } +} diff --git a/src/SharedWeb/SharedWeb.csproj b/src/SharedWeb/SharedWeb.csproj index 8bffa285fc..d8dc61178d 100644 --- a/src/SharedWeb/SharedWeb.csproj +++ b/src/SharedWeb/SharedWeb.csproj @@ -7,6 +7,7 @@ + diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 58ce0466c3..bc8df87599 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ using Bit.Core; using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Models.Teams; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services.Implementations; @@ -69,6 +70,8 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.Azure.Cosmos.Fluent; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; using Microsoft.Extensions.Caching.Cosmos; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Configuration; @@ -604,6 +607,33 @@ public static class ServiceCollectionExtensions return services; } + public static IServiceCollection AddTeamsService(this IServiceCollection services, GlobalSettings globalSettings) + { + if (CoreHelpers.SettingHasValue(globalSettings.Teams.ClientId) && + CoreHelpers.SettingHasValue(globalSettings.Teams.ClientSecret) && + CoreHelpers.SettingHasValue(globalSettings.Teams.Scopes)) + { + services.AddHttpClient(TeamsService.HttpClientName); + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(sp => + new BotFrameworkHttpAdapter( + new TeamsBotCredentialProvider( + clientId: globalSettings.Teams.ClientId, + clientSecret: globalSettings.Teams.ClientSecret + ) + ) + ); + } + else + { + services.TryAddSingleton(); + } + + return services; + } + public static void UseDefaultMiddleware(this IApplicationBuilder app, IWebHostEnvironment env, GlobalSettings globalSettings) { @@ -913,6 +943,7 @@ public static class ServiceCollectionExtensions // Add services in support of handlers services.AddSlackService(globalSettings); + services.AddTeamsService(globalSettings); services.TryAddSingleton(TimeProvider.System); services.AddHttpClient(WebhookIntegrationHandler.HttpClientName); services.AddHttpClient(DatadogIntegrationHandler.HttpClientName); @@ -921,12 +952,14 @@ public static class ServiceCollectionExtensions services.TryAddSingleton, SlackIntegrationHandler>(); services.TryAddSingleton, WebhookIntegrationHandler>(); services.TryAddSingleton, DatadogIntegrationHandler>(); + services.TryAddSingleton, TeamsIntegrationHandler>(); var repositoryConfiguration = new RepositoryListenerConfiguration(globalSettings); var slackConfiguration = new SlackListenerConfiguration(globalSettings); var webhookConfiguration = new WebhookListenerConfiguration(globalSettings); var hecConfiguration = new HecListenerConfiguration(globalSettings); var datadogConfiguration = new DatadogListenerConfiguration(globalSettings); + var teamsConfiguration = new TeamsListenerConfiguration(globalSettings); if (IsRabbitMqEnabled(globalSettings)) { @@ -944,6 +977,7 @@ public static class ServiceCollectionExtensions services.AddRabbitMqIntegration(webhookConfiguration); services.AddRabbitMqIntegration(hecConfiguration); services.AddRabbitMqIntegration(datadogConfiguration); + services.AddRabbitMqIntegration(teamsConfiguration); } if (IsAzureServiceBusEnabled(globalSettings)) @@ -967,6 +1001,7 @@ public static class ServiceCollectionExtensions services.AddAzureServiceBusIntegration(webhookConfiguration); services.AddAzureServiceBusIntegration(hecConfiguration); services.AddAzureServiceBusIntegration(datadogConfiguration); + services.AddAzureServiceBusIntegration(teamsConfiguration); } return services; diff --git a/src/Sql/dbo/Stored Procedures/OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId.sql b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId.sql new file mode 100644 index 0000000000..8e2102772b --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId] + @TenantId NVARCHAR(200), + @TeamId NVARCHAR(200) +AS +BEGIN + SET NOCOUNT ON; + +SELECT TOP 1 * +FROM [dbo].[OrganizationIntegrationView] + CROSS APPLY OPENJSON([Configuration], '$.Teams') + WITH ( TeamId NVARCHAR(MAX) '$.id' ) t +WHERE [Type] = 7 + AND JSON_VALUE([Configuration], '$.TenantId') = @TenantId + AND t.TeamId = @TeamId + AND JSON_VALUE([Configuration], '$.ChannelId') IS NULL + AND JSON_VALUE([Configuration], '$.ServiceUrl') IS NULL; +END diff --git a/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs index 376fb01493..61d3486c51 100644 --- a/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs @@ -34,7 +34,7 @@ public class SlackIntegrationControllerTests integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) @@ -60,7 +60,7 @@ public class SlackIntegrationControllerTests integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .GetByIdAsync(integration.Id) @@ -80,7 +80,7 @@ public class SlackIntegrationControllerTests integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .GetByIdAsync(integration.Id) @@ -99,13 +99,13 @@ public class SlackIntegrationControllerTests { sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) .Returns(_slackToken); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, String.Empty)); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, string.Empty)); } [Theory, BitAutoData] @@ -116,7 +116,7 @@ public class SlackIntegrationControllerTests var timeProvider = new FakeTimeProvider(new DateTime(2024, 4, 3, 2, 1, 0, DateTimeKind.Utc)); sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) @@ -135,7 +135,7 @@ public class SlackIntegrationControllerTests { sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) @@ -147,7 +147,7 @@ public class SlackIntegrationControllerTests } [Theory, BitAutoData] - public async Task CreateAsync_StateHasWrongOgranizationHash_ThrowsNotFound( + public async Task CreateAsync_StateHasWrongOrganizationHash_ThrowsNotFound( SutProvider sutProvider, OrganizationIntegration integration, OrganizationIntegration wrongOrgIntegration) @@ -156,7 +156,7 @@ public class SlackIntegrationControllerTests sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) @@ -179,7 +179,7 @@ public class SlackIntegrationControllerTests integration.Configuration = "{}"; sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) @@ -201,7 +201,7 @@ public class SlackIntegrationControllerTests integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns("https://localhost"); sutProvider.GetDependency() .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) @@ -224,7 +224,7 @@ public class SlackIntegrationControllerTests sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns(expectedUrl); sutProvider.GetDependency() .OrganizationOwner(integration.OrganizationId) @@ -260,7 +260,7 @@ public class SlackIntegrationControllerTests sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns(expectedUrl); sutProvider.GetDependency() .OrganizationOwner(organizationId) @@ -291,7 +291,7 @@ public class SlackIntegrationControllerTests sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns(expectedUrl); sutProvider.GetDependency() .OrganizationOwner(organizationId) @@ -316,7 +316,7 @@ public class SlackIntegrationControllerTests sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url - .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) .Returns(expectedUrl); sutProvider.GetDependency() .OrganizationOwner(organizationId) diff --git a/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs new file mode 100644 index 0000000000..3af2affdd8 --- /dev/null +++ b/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs @@ -0,0 +1,392 @@ +#nullable enable + +using Bit.Api.AdminConsole.Controllers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Models.Teams; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Bot.Builder; +using Microsoft.Bot.Builder.Integration.AspNet.Core; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Controllers; + +[ControllerCustomize(typeof(TeamsIntegrationController))] +[SutProviderCustomize] +public class TeamsIntegrationControllerTests +{ + private const string _teamsToken = "test-token"; + private const string _validTeamsCode = "A_test_code"; + + [Theory, BitAutoData] + public async Task CreateAsync_AllParamsProvided_Succeeds( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Teams; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + sutProvider.GetDependency() + .GetJoinedTeamsAsync(_teamsToken) + .Returns([ + new TeamInfo() { DisplayName = "Test Team", Id = Guid.NewGuid().ToString(), TenantId = Guid.NewGuid().ToString() } + ]); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + var requestAction = await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()); + + await sutProvider.GetDependency().Received(1) + .UpsertAsync(Arg.Any()); + Assert.IsType(requestAction); + } + + [Theory, BitAutoData] + public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Teams; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateAsync(string.Empty, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_NoTeamsFound_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Teams; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + sutProvider.GetDependency() + .GetJoinedTeamsAsync(_teamsToken) + .Returns([]); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_TeamsServiceReturnsEmptyToken_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Teams; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(string.Empty); + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateEmpty_ThrowsNotFound( + SutProvider sutProvider) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, string.Empty)); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateExpired_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + var timeProvider = new FakeTimeProvider(new DateTime(2024, 4, 3, 2, 1, 0, DateTimeKind.Utc)); + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + var state = IntegrationOAuthState.FromIntegration(integration, timeProvider); + timeProvider.Advance(TimeSpan.FromMinutes(30)); + + sutProvider.SetDependency(timeProvider); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasNonexistentIntegration_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasWrongOrganizationHash_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration, + OrganizationIntegration wrongOrgIntegration) + { + wrongOrgIntegration.Id = integration.Id; + wrongOrgIntegration.Type = IntegrationType.Teams; + wrongOrgIntegration.Configuration = null; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(wrongOrgIntegration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasNonEmptyIntegration_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Teams; + integration.Configuration = "{}"; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasNonTeamsIntegration_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Hec; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validTeamsCode, Arg.Any()) + .Returns(_teamsToken); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_Success( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Configuration = null; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns(expectedUrl); + sutProvider.GetDependency() + .OrganizationOwner(integration.OrganizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(integration.OrganizationId) + .Returns([]); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(integration); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(expectedUrl); + + var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + var requestAction = await sutProvider.Sut.RedirectAsync(integration.OrganizationId); + + Assert.IsType(requestAction); + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Any()); + sutProvider.GetDependency().Received(1).GetRedirectUrl(Arg.Any(), expectedState.ToString()); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_IntegrationAlreadyExistsWithNullConfig_Success( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) + { + integration.OrganizationId = organizationId; + integration.Configuration = null; + integration.Type = IntegrationType.Teams; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns(expectedUrl); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([integration]); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(expectedUrl); + + var requestAction = await sutProvider.Sut.RedirectAsync(organizationId); + + var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + Assert.IsType(requestAction); + sutProvider.GetDependency().Received(1).GetRedirectUrl(Arg.Any(), expectedState.ToString()); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_IntegrationAlreadyExistsWithConfig_ThrowsBadRequest( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) + { + integration.OrganizationId = organizationId; + integration.Configuration = "{}"; + integration.Type = IntegrationType.Teams; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns(expectedUrl); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([integration]); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(expectedUrl); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_TeamsServiceReturnsEmpty_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) + { + integration.OrganizationId = organizationId; + integration.Configuration = null; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns(expectedUrl); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([]); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(integration); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(string.Empty); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, + Guid organizationId) + { + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(false); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } + + [Theory, BitAutoData] + public async Task IncomingPostAsync_ForwardsToBot(SutProvider sutProvider) + { + var adapter = sutProvider.GetDependency(); + var bot = sutProvider.GetDependency(); + + await sutProvider.Sut.IncomingPostAsync(); + await adapter.Received(1).ProcessAsync(Arg.Any(), Arg.Any(), bot); + } +} diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs index 74fe75a9d7..8a75db9da8 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs @@ -39,7 +39,7 @@ public class OrganizationIntegrationConfigurationRequestModelTests [Theory] [InlineData(data: "")] [InlineData(data: " ")] - public void IsValidForType_EmptyNonNullHecConfiguration_ReturnsFalse(string? config) + public void IsValidForType_EmptyNonNullConfiguration_ReturnsFalse(string? config) { var model = new OrganizationIntegrationConfigurationRequestModel { @@ -48,10 +48,12 @@ public class OrganizationIntegrationConfigurationRequestModelTests }; Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); + Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); + Assert.False(condition: model.IsValidForType(IntegrationType.Teams)); } [Fact] - public void IsValidForType_NullHecConfiguration_ReturnsTrue() + public void IsValidForType_NullConfiguration_ReturnsTrue() { var model = new OrganizationIntegrationConfigurationRequestModel { @@ -60,32 +62,8 @@ public class OrganizationIntegrationConfigurationRequestModelTests }; Assert.True(condition: model.IsValidForType(IntegrationType.Hec)); - } - - [Theory] - [InlineData(data: "")] - [InlineData(data: " ")] - public void IsValidForType_EmptyNonNullDatadogConfiguration_ReturnsFalse(string? config) - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = config, - Template = "template" - }; - - Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); - } - - [Fact] - public void IsValidForType_NullDatadogConfiguration_ReturnsTrue() - { - var model = new OrganizationIntegrationConfigurationRequestModel - { - Configuration = null, - Template = "template" - }; - Assert.True(condition: model.IsValidForType(IntegrationType.Datadog)); + Assert.True(condition: model.IsValidForType(IntegrationType.Teams)); } [Theory] @@ -107,6 +85,8 @@ public class OrganizationIntegrationConfigurationRequestModelTests Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); + Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); + Assert.False(condition: model.IsValidForType(IntegrationType.Teams)); } [Fact] @@ -121,6 +101,8 @@ public class OrganizationIntegrationConfigurationRequestModelTests Assert.False(condition: model.IsValidForType(IntegrationType.Slack)); Assert.False(condition: model.IsValidForType(IntegrationType.Webhook)); Assert.False(condition: model.IsValidForType(IntegrationType.Hec)); + Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); + Assert.False(condition: model.IsValidForType(IntegrationType.Teams)); } diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs index 81927a1bfe..1303e5fe89 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs @@ -57,6 +57,22 @@ public class OrganizationIntegrationRequestModelTests Assert.Contains("cannot be created directly", results[0].ErrorMessage); } + [Fact] + public void Validate_Teams_ReturnsCannotBeCreatedDirectlyError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Teams, + Configuration = null + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Type), results[0].MemberNames); + Assert.Contains("cannot be created directly", results[0].ErrorMessage); + } + [Fact] public void Validate_Webhook_WithNullConfiguration_ReturnsNoErrors() { diff --git a/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs b/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs index babdf3894d..28bc07de38 100644 --- a/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs @@ -1,8 +1,11 @@ #nullable enable +using System.Text.Json; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; +using Bit.Core.Models.Teams; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; @@ -58,6 +61,46 @@ public class OrganizationIntegrationResponseModelTests Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); } + [Theory, BitAutoData] + public void Status_Teams_NullConfig_ReturnsInitiated(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Teams; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Initiated, model.Status); + } + + [Theory, BitAutoData] + public void Status_Teams_WithTenantAndTeamsConfig_ReturnsInProgress(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Teams; + oi.Configuration = JsonSerializer.Serialize(new TeamsIntegration( + TenantId: "tenant", Teams: [new TeamInfo() { DisplayName = "Team", Id = "TeamId", TenantId = "tenant" }] + )); + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.InProgress, model.Status); + } + + [Theory, BitAutoData] + public void Status_Teams_WithCompletedConfig_ReturnsCompleted(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Teams; + oi.Configuration = JsonSerializer.Serialize(new TeamsIntegration( + TenantId: "tenant", + Teams: [new TeamInfo() { DisplayName = "Team", Id = "TeamId", TenantId = "tenant" }], + ServiceUrl: new Uri("https://example.com"), + ChannelId: "channellId" + )); + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); + } + [Theory, BitAutoData] public void Status_Webhook_AlwaysCompleted(OrganizationIntegration oi) { diff --git a/test/Core.Test/AdminConsole/Models/Data/Teams/TeamsBotCredentialProviderTests.cs b/test/Core.Test/AdminConsole/Models/Data/Teams/TeamsBotCredentialProviderTests.cs new file mode 100644 index 0000000000..d3d433727f --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/Data/Teams/TeamsBotCredentialProviderTests.cs @@ -0,0 +1,56 @@ +using Bit.Core.AdminConsole.Models.Teams; +using Microsoft.Bot.Connector.Authentication; +using Xunit; + +namespace Bit.Core.Test.Models.Data.Teams; + +public class TeamsBotCredentialProviderTests +{ + private string _clientId = "client id"; + private string _clientSecret = "client secret"; + + [Fact] + public async Task IsValidAppId_MustMatchClientId() + { + var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret); + + Assert.True(await sut.IsValidAppIdAsync(_clientId)); + Assert.False(await sut.IsValidAppIdAsync("Different id")); + } + + [Fact] + public async Task GetAppPasswordAsync_MatchingClientId_ReturnsClientSecret() + { + var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret); + var password = await sut.GetAppPasswordAsync(_clientId); + Assert.Equal(_clientSecret, password); + } + + [Fact] + public async Task GetAppPasswordAsync_NotMatchingClientId_ReturnsNull() + { + var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret); + Assert.Null(await sut.GetAppPasswordAsync("Different id")); + } + + [Fact] + public async Task IsAuthenticationDisabledAsync_ReturnsFalse() + { + var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret); + Assert.False(await sut.IsAuthenticationDisabledAsync()); + } + + [Fact] + public async Task ValidateIssuerAsync_ExpectedIssuer_ReturnsTrue() + { + var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret); + Assert.True(await sut.ValidateIssuerAsync(AuthenticationConstants.ToBotFromChannelTokenIssuer)); + } + + [Fact] + public async Task ValidateIssuerAsync_UnexpectedIssuer_ReturnsFalse() + { + var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret); + Assert.False(await sut.ValidateIssuerAsync("unexpected issuer")); + } +} diff --git a/test/Core.Test/AdminConsole/Services/IntegrationTypeTests.cs b/test/Core.Test/AdminConsole/Services/IntegrationTypeTests.cs index 98cf974df8..715bffaab1 100644 --- a/test/Core.Test/AdminConsole/Services/IntegrationTypeTests.cs +++ b/test/Core.Test/AdminConsole/Services/IntegrationTypeTests.cs @@ -5,17 +5,6 @@ namespace Bit.Core.Test.Services; public class IntegrationTypeTests { - [Fact] - public void ToRoutingKey_Slack_Succeeds() - { - Assert.Equal("slack", IntegrationType.Slack.ToRoutingKey()); - } - [Fact] - public void ToRoutingKey_Webhook_Succeeds() - { - Assert.Equal("webhook", IntegrationType.Webhook.ToRoutingKey()); - } - [Fact] public void ToRoutingKey_CloudBillingSync_ThrowsException() { @@ -27,4 +16,34 @@ public class IntegrationTypeTests { Assert.Throws(() => IntegrationType.Scim.ToRoutingKey()); } + + [Fact] + public void ToRoutingKey_Slack_Succeeds() + { + Assert.Equal("slack", IntegrationType.Slack.ToRoutingKey()); + } + + [Fact] + public void ToRoutingKey_Webhook_Succeeds() + { + Assert.Equal("webhook", IntegrationType.Webhook.ToRoutingKey()); + } + + [Fact] + public void ToRoutingKey_Hec_Succeeds() + { + Assert.Equal("hec", IntegrationType.Hec.ToRoutingKey()); + } + + [Fact] + public void ToRoutingKey_Datadog_Succeeds() + { + Assert.Equal("datadog", IntegrationType.Datadog.ToRoutingKey()); + } + + [Fact] + public void ToRoutingKey_Teams_Succeeds() + { + Assert.Equal("teams", IntegrationType.Teams.ToRoutingKey()); + } } diff --git a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs index 2d0ca2433a..48dd9c490e 100644 --- a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs @@ -296,6 +296,18 @@ public class SlackServiceTests Assert.Equal("test-access-token", result); } + [Theory] + [InlineData("test-code", "")] + [InlineData("", "https://example.com/callback")] + [InlineData("", "")] + public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenCodeOrRedirectUrlIsEmpty(string code, string redirectUrl) + { + var sutProvider = GetSutProvider(); + var result = await sutProvider.Sut.ObtainTokenViaOAuth(code, redirectUrl); + + Assert.Equal(string.Empty, result); + } + [Fact] public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenErrorResponse() { diff --git a/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs new file mode 100644 index 0000000000..b744a6aa69 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/TeamsIntegrationHandlerTests.cs @@ -0,0 +1,126 @@ +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using Microsoft.Rest; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class TeamsIntegrationHandlerTests +{ + private readonly ITeamsService _teamsService = Substitute.For(); + private readonly string _channelId = "C12345"; + private readonly Uri _serviceUrl = new Uri("http://localhost"); + + private SutProvider GetSutProvider() + { + return new SutProvider() + .SetDependency(_teamsService) + .Create(); + } + + [Theory, BitAutoData] + public async Task HandleAsync_SuccessfulRequest_ReturnsSuccess(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.True(result.Success); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } + + + [Theory, BitAutoData] + public async Task HandleAsync_HttpExceptionNonRetryable_ReturnsFalseAndNotRetryable(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + sutProvider.GetDependency() + .SendMessageToChannelAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new HttpOperationException("Server error") + { + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(System.Net.HttpStatusCode.Forbidden), + "Forbidden" + ) + } + ); + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.False(result.Retryable); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } + + [Theory, BitAutoData] + public async Task HandleAsync_HttpExceptionRetryable_ReturnsFalseAndRetryable(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + sutProvider.GetDependency() + .SendMessageToChannelAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new HttpOperationException("Server error") + { + Response = new HttpResponseMessageWrapper( + new HttpResponseMessage(System.Net.HttpStatusCode.TooManyRequests), + "Too Many Requests" + ) + } + ); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.True(result.Retryable); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } + + [Theory, BitAutoData] + public async Task HandleAsync_UnknownException_ReturnsFalseAndNotRetryable(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl); + + sutProvider.GetDependency() + .SendMessageToChannelAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception("Unknown error")); + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.False(result.Retryable); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendMessageToChannelAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)) + ); + } +} diff --git a/test/Core.Test/AdminConsole/Services/TeamsServiceTests.cs b/test/Core.Test/AdminConsole/Services/TeamsServiceTests.cs new file mode 100644 index 0000000000..17d65f3237 --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/TeamsServiceTests.cs @@ -0,0 +1,289 @@ +#nullable enable + +using System.Net; +using System.Text.Json; +using System.Web; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Models.Teams; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.MockedHttpClient; +using NSubstitute; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class TeamsServiceTests +{ + private readonly MockedHttpMessageHandler _handler; + private readonly HttpClient _httpClient; + + public TeamsServiceTests() + { + _handler = new MockedHttpMessageHandler(); + _httpClient = _handler.ToHttpClient(); + } + + private SutProvider GetSutProvider() + { + var clientFactory = Substitute.For(); + clientFactory.CreateClient(TeamsService.HttpClientName).Returns(_httpClient); + + var globalSettings = Substitute.For(); + globalSettings.Teams.LoginBaseUrl.Returns("https://login.example.com"); + globalSettings.Teams.GraphBaseUrl.Returns("https://graph.example.com"); + + return new SutProvider() + .SetDependency(clientFactory) + .SetDependency(globalSettings) + .Create(); + } + + [Fact] + public void GetRedirectUrl_ReturnsCorrectUrl() + { + var sutProvider = GetSutProvider(); + var clientId = sutProvider.GetDependency().Teams.ClientId; + var scopes = sutProvider.GetDependency().Teams.Scopes; + var callbackUrl = "https://example.com/callback"; + var state = Guid.NewGuid().ToString(); + var result = sutProvider.Sut.GetRedirectUrl(callbackUrl, state); + + var uri = new Uri(result); + var query = HttpUtility.ParseQueryString(uri.Query); + + Assert.Equal(clientId, query["client_id"]); + Assert.Equal(scopes, query["scope"]); + Assert.Equal(callbackUrl, query["redirect_uri"]); + Assert.Equal(state, query["state"]); + Assert.Equal("login.example.com", uri.Host); + Assert.Equal("/common/oauth2/v2.0/authorize", uri.AbsolutePath); + } + + [Fact] + public async Task ObtainTokenViaOAuth_Success_ReturnsAccessToken() + { + var sutProvider = GetSutProvider(); + var jsonResponse = JsonSerializer.Serialize(new + { + access_token = "test-access-token" + }); + + _handler.When("https://login.example.com/common/oauth2/v2.0/token") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(jsonResponse)); + + var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback"); + + Assert.Equal("test-access-token", result); + } + + [Theory] + [InlineData("test-code", "")] + [InlineData("", "https://example.com/callback")] + [InlineData("", "")] + public async Task ObtainTokenViaOAuth_CodeOrRedirectUrlIsEmpty_ReturnsEmptyString(string code, string redirectUrl) + { + var sutProvider = GetSutProvider(); + var result = await sutProvider.Sut.ObtainTokenViaOAuth(code, redirectUrl); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public async Task ObtainTokenViaOAuth_HttpFailure_ReturnsEmptyString() + { + var sutProvider = GetSutProvider(); + _handler.When("https://login.example.com/common/oauth2/v2.0/token") + .RespondWith(HttpStatusCode.InternalServerError) + .WithContent(new StringContent(string.Empty)); + + var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback"); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public async Task ObtainTokenViaOAuth_UnknownResponse_ReturnsEmptyString() + { + var sutProvider = GetSutProvider(); + + _handler.When("https://login.example.com/common/oauth2/v2.0/token") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("Not an expected response")); + + var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback"); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public async Task GetJoinedTeamsAsync_Success_ReturnsTeams() + { + var sutProvider = GetSutProvider(); + + var jsonResponse = JsonSerializer.Serialize(new + { + value = new[] + { + new { id = "team1", displayName = "Team One" }, + new { id = "team2", displayName = "Team Two" } + } + }); + + _handler.When("https://graph.example.com/me/joinedTeams") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(jsonResponse)); + + var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token"); + + Assert.Equal(2, result.Count); + Assert.Contains(result, t => t is { Id: "team1", DisplayName: "Team One" }); + Assert.Contains(result, t => t is { Id: "team2", DisplayName: "Team Two" }); + } + + [Fact] + public async Task GetJoinedTeamsAsync_ServerReturnsEmpty_ReturnsEmptyList() + { + var sutProvider = GetSutProvider(); + + var jsonResponse = JsonSerializer.Serialize(new { value = (object?)null }); + + _handler.When("https://graph.example.com/me/joinedTeams") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(jsonResponse)); + + var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token"); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetJoinedTeamsAsync_ServerErrorCode_ReturnsEmptyList() + { + var sutProvider = GetSutProvider(); + + _handler.When("https://graph.example.com/me/joinedTeams") + .RespondWith(HttpStatusCode.Unauthorized) + .WithContent(new StringContent("Unauthorized")); + + var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token"); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory, BitAutoData] + public async Task HandleIncomingAppInstall_Success_UpdatesTeamsIntegration( + OrganizationIntegration integration) + { + var sutProvider = GetSutProvider(); + var tenantId = Guid.NewGuid().ToString(); + var teamId = Guid.NewGuid().ToString(); + var conversationId = Guid.NewGuid().ToString(); + var serviceUrl = new Uri("https://localhost"); + var initiatedConfiguration = new TeamsIntegration(TenantId: tenantId, Teams: + [ + new TeamInfo() { Id = teamId, DisplayName = "test team", TenantId = tenantId }, + new TeamInfo() { Id = Guid.NewGuid().ToString(), DisplayName = "other team", TenantId = tenantId }, + new TeamInfo() { Id = Guid.NewGuid().ToString(), DisplayName = "third team", TenantId = tenantId } + ]); + integration.Configuration = JsonSerializer.Serialize(initiatedConfiguration); + + sutProvider.GetDependency() + .GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId) + .Returns(integration); + + OrganizationIntegration? capturedIntegration = null; + await sutProvider.GetDependency() + .UpsertAsync(Arg.Do(x => capturedIntegration = x)); + + await sutProvider.Sut.HandleIncomingAppInstallAsync( + conversationId: conversationId, + serviceUrl: serviceUrl, + teamId: teamId, + tenantId: tenantId + ); + + await sutProvider.GetDependency().Received(1).GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId); + Assert.NotNull(capturedIntegration); + var configuration = JsonSerializer.Deserialize(capturedIntegration.Configuration ?? string.Empty); + Assert.NotNull(configuration); + Assert.NotNull(configuration.ServiceUrl); + Assert.Equal(serviceUrl, configuration.ServiceUrl); + Assert.Equal(conversationId, configuration.ChannelId); + } + + [Fact] + public async Task HandleIncomingAppInstall_NoIntegrationMatched_DoesNothing() + { + var sutProvider = GetSutProvider(); + await sutProvider.Sut.HandleIncomingAppInstallAsync( + conversationId: "conversationId", + serviceUrl: new Uri("https://localhost"), + teamId: "teamId", + tenantId: "tenantId" + ); + + await sutProvider.GetDependency().Received(1).GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId"); + await sutProvider.GetDependency().DidNotReceive().UpsertAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task HandleIncomingAppInstall_MatchedIntegrationAlreadySetup_DoesNothing( + OrganizationIntegration integration) + { + var sutProvider = GetSutProvider(); + var tenantId = Guid.NewGuid().ToString(); + var teamId = Guid.NewGuid().ToString(); + var initiatedConfiguration = new TeamsIntegration( + TenantId: tenantId, + Teams: [new TeamInfo() { Id = teamId, DisplayName = "test team", TenantId = tenantId }], + ChannelId: "ChannelId", + ServiceUrl: new Uri("https://localhost") + ); + integration.Configuration = JsonSerializer.Serialize(initiatedConfiguration); + + sutProvider.GetDependency() + .GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId) + .Returns(integration); + + await sutProvider.Sut.HandleIncomingAppInstallAsync( + conversationId: "conversationId", + serviceUrl: new Uri("https://localhost"), + teamId: teamId, + tenantId: tenantId + ); + + await sutProvider.GetDependency().Received(1).GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId); + await sutProvider.GetDependency().DidNotReceive().UpsertAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task HandleIncomingAppInstall_MatchedIntegrationWithMissingConfiguration_DoesNothing( + OrganizationIntegration integration) + { + var sutProvider = GetSutProvider(); + integration.Configuration = null; + + sutProvider.GetDependency() + .GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId") + .Returns(integration); + + await sutProvider.Sut.HandleIncomingAppInstallAsync( + conversationId: "conversationId", + serviceUrl: new Uri("https://localhost"), + teamId: "teamId", + tenantId: "tenantId" + ); + + await sutProvider.GetDependency().Received(1).GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId"); + await sutProvider.GetDependency().DidNotReceive().UpsertAsync(Arg.Any()); + } +} diff --git a/util/Migrator/DbScripts/2025-10-3_00_AddOrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamIdStoredProcedure.sql b/util/Migrator/DbScripts/2025-10-3_00_AddOrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamIdStoredProcedure.sql new file mode 100644 index 0000000000..16ac419cad --- /dev/null +++ b/util/Migrator/DbScripts/2025-10-3_00_AddOrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamIdStoredProcedure.sql @@ -0,0 +1,18 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationIntegration_ReadByTeamsConfigurationTenantIdTeamId] + @TenantId NVARCHAR(200), + @TeamId NVARCHAR(200) +AS +BEGIN + SET NOCOUNT ON; + +SELECT TOP 1 * +FROM [dbo].[OrganizationIntegrationView] + CROSS APPLY OPENJSON([Configuration], '$.Teams') + WITH ( TeamId NVARCHAR(MAX) '$.id' ) t +WHERE [Type] = 7 + AND JSON_VALUE([Configuration], '$.TenantId') = @TenantId + AND t.TeamId = @TeamId + AND JSON_VALUE([Configuration], '$.ChannelId') IS NULL + AND JSON_VALUE([Configuration], '$.ServiceUrl') IS NULL; +END +GO From 60721041531730378359d787ff09e8251b4655bf Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Fri, 10 Oct 2025 11:23:02 -0400 Subject: [PATCH 08/14] Ac/pm 25823/vnext policy upsert pattern (#6426) --- .../Implementations/VNextSavePolicyCommand.cs | 208 ++++++++ .../Policies/Models/PolicyUpdate.cs | 2 + .../PolicyServiceCollectionExtensions.cs | 4 + .../IEnforceDependentPoliciesEvent.cs | 12 + .../Interfaces/IOnPolicyPostUpdateEvent.cs | 18 + .../Interfaces/IOnPolicyPreUpdateEvent.cs | 17 + .../Interfaces/IPolicyEventHandlerFactory.cs | 30 ++ .../Interfaces/IPolicyUpdateEvent.cs | 11 + .../Interfaces/IPolicyValidationEvent.cs | 19 + .../Interfaces/IVNextSavePolicyCommand.cs | 34 ++ .../PolicyEventHandlerHandlerFactory.cs | 33 ++ .../PolicyEventHandlerHandlerFactoryTests.cs | 124 +++++ .../Policies/PolicyUpdateEventFixtures.cs | 37 ++ .../Policies/VNextSavePolicyCommandTests.cs | 471 ++++++++++++++++++ 14 files changed, 1020 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPostUpdateEvent.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyEventHandlerFactory.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IVNextSavePolicyCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/PolicyEventHandlerHandlerFactory.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlerHandlerFactoryTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEventFixtures.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs new file mode 100644 index 0000000000..1a2b78fc8a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs @@ -0,0 +1,208 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; + +public class VNextSavePolicyCommand( + IApplicationCacheService applicationCacheService, + IEventService eventService, + IPolicyRepository policyRepository, + IEnumerable policyValidationEventHandlers, + TimeProvider timeProvider, + IPolicyEventHandlerFactory policyEventHandlerFactory) + : IVNextSavePolicyCommand +{ + private readonly IReadOnlyDictionary _policyValidationEvents = MapToDictionary(policyValidationEventHandlers); + + private static Dictionary MapToDictionary(IEnumerable policyValidationEventHandlers) + { + var policyValidationEventsDict = new Dictionary(); + foreach (var policyValidationEvent in policyValidationEventHandlers) + { + if (!policyValidationEventsDict.TryAdd(policyValidationEvent.Type, policyValidationEvent)) + { + throw new Exception($"Duplicate PolicyValidationEvent for {policyValidationEvent.Type} policy."); + } + } + return policyValidationEventsDict; + } + + public async Task SaveAsync(SavePolicyModel policyRequest) + { + var policyUpdateRequest = policyRequest.PolicyUpdate; + var organizationId = policyUpdateRequest.OrganizationId; + + await EnsureOrganizationCanUsePolicyAsync(organizationId); + + var savedPoliciesDict = await GetCurrentPolicyStateAsync(organizationId); + + var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdateRequest.Type); + + ValidatePolicyDependencies(policyUpdateRequest, currentPolicy, savedPoliciesDict); + + await ValidateTargetedPolicyAsync(policyRequest, currentPolicy); + + await ExecutePreUpsertSideEffectAsync(policyRequest, currentPolicy); + + var upsertedPolicy = await UpsertPolicyAsync(policyUpdateRequest); + + await eventService.LogPolicyEventAsync(upsertedPolicy, EventType.Policy_Updated); + + await ExecutePostUpsertSideEffectAsync(policyRequest, upsertedPolicy, currentPolicy); + + return upsertedPolicy; + } + + private async Task EnsureOrganizationCanUsePolicyAsync(Guid organizationId) + { + var org = await applicationCacheService.GetOrganizationAbilityAsync(organizationId); + if (org == null) + { + throw new BadRequestException("Organization not found"); + } + + if (!org.UsePolicies) + { + throw new BadRequestException("This organization cannot use policies."); + } + } + + private async Task UpsertPolicyAsync(PolicyUpdate policyUpdateRequest) + { + var policy = await policyRepository.GetByOrganizationIdTypeAsync(policyUpdateRequest.OrganizationId, policyUpdateRequest.Type) + ?? new Policy + { + OrganizationId = policyUpdateRequest.OrganizationId, + Type = policyUpdateRequest.Type, + CreationDate = timeProvider.GetUtcNow().UtcDateTime + }; + + policy.Enabled = policyUpdateRequest.Enabled; + policy.Data = policyUpdateRequest.Data; + policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime; + + await policyRepository.UpsertAsync(policy); + + return policy; + } + + private async Task ValidateTargetedPolicyAsync(SavePolicyModel policyRequest, + Policy? currentPolicy) + { + await ExecutePolicyEventAsync( + policyRequest.PolicyUpdate.Type, + async validator => + { + var validationError = await validator.ValidateAsync(policyRequest, currentPolicy); + if (!string.IsNullOrEmpty(validationError)) + { + throw new BadRequestException(validationError); + } + }); + } + + private void ValidatePolicyDependencies( + PolicyUpdate policyUpdateRequest, + Policy? currentPolicy, + Dictionary savedPoliciesDict) + { + var result = policyEventHandlerFactory.GetHandler(policyUpdateRequest.Type); + + result.Switch( + validator => + { + var isCurrentlyEnabled = currentPolicy?.Enabled == true; + + switch (policyUpdateRequest.Enabled) + { + case true when !isCurrentlyEnabled: + ValidateEnablingRequirements(validator, savedPoliciesDict); + return; + case false when isCurrentlyEnabled: + ValidateDisablingRequirements(validator, policyUpdateRequest.Type, savedPoliciesDict); + break; + } + }, + _ => { }); + } + + private void ValidateDisablingRequirements( + IEnforceDependentPoliciesEvent validator, + PolicyType policyType, + Dictionary savedPoliciesDict) + { + var dependentPolicyTypes = _policyValidationEvents.Values + .Where(otherValidator => otherValidator.RequiredPolicies.Contains(policyType)) + .Select(otherValidator => otherValidator.Type) + .Where(otherPolicyType => savedPoliciesDict.TryGetValue(otherPolicyType, out var savedPolicy) && + savedPolicy.Enabled) + .ToList(); + + switch (dependentPolicyTypes) + { + case { Count: 1 }: + throw new BadRequestException($"Turn off the {dependentPolicyTypes.First().GetName()} policy because it requires the {validator.Type.GetName()} policy."); + case { Count: > 1 }: + throw new BadRequestException($"Turn off all of the policies that require the {validator.Type.GetName()} policy."); + } + } + + private static void ValidateEnablingRequirements( + IEnforceDependentPoliciesEvent validator, + Dictionary savedPoliciesDict) + { + var missingRequiredPolicyTypes = validator.RequiredPolicies + .Where(requiredPolicyType => savedPoliciesDict.GetValueOrDefault(requiredPolicyType) is not { Enabled: true }) + .ToList(); + + if (missingRequiredPolicyTypes.Count != 0) + { + throw new BadRequestException($"Turn on the {missingRequiredPolicyTypes.First().GetName()} policy because it is required for the {validator.Type.GetName()} policy."); + } + } + + private async Task ExecutePreUpsertSideEffectAsync( + SavePolicyModel policyRequest, + Policy? currentPolicy) + { + await ExecutePolicyEventAsync( + policyRequest.PolicyUpdate.Type, + handler => handler.ExecutePreUpsertSideEffectAsync(policyRequest, currentPolicy)); + } + private async Task ExecutePostUpsertSideEffectAsync( + SavePolicyModel policyRequest, + Policy postUpsertedPolicyState, + Policy? previousPolicyState) + { + await ExecutePolicyEventAsync( + policyRequest.PolicyUpdate.Type, + handler => handler.ExecutePostUpsertSideEffectAsync( + policyRequest, + postUpsertedPolicyState, + previousPolicyState)); + } + + private async Task ExecutePolicyEventAsync(PolicyType type, Func func) where T : IPolicyUpdateEvent + { + var handler = policyEventHandlerFactory.GetHandler(type); + + await handler.Match( + async h => await func(h), + _ => Task.CompletedTask + ); + } + + private async Task> GetCurrentPolicyStateAsync(Guid organizationId) + { + var savedPolicies = await policyRepository.GetManyByOrganizationIdAsync(organizationId); + // Note: policies may be missing from this dict if they have never been enabled + var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type); + return savedPoliciesDict; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs index d1a52f0080..cad786234c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/PolicyUpdate.cs @@ -16,6 +16,8 @@ public record PolicyUpdate public PolicyType Type { get; set; } public string? Data { get; set; } public bool Enabled { get; set; } + + [Obsolete("Please use SavePolicyModel.PerformedBy instead.")] public IActingUser? PerformedBy { get; set; } public T GetDataModel() where T : IPolicyDataModel, new() diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 5433d70410..f35ff87424 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services.Implementations; @@ -13,7 +15,9 @@ public static class PolicyServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddPolicyValidators(); services.AddPolicyRequirements(); diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs new file mode 100644 index 0000000000..798417ae7c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs @@ -0,0 +1,12 @@ +using Bit.Core.AdminConsole.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; + +public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent +{ + /// + /// PolicyTypes that must be enabled before this policy can be enabled, if any. + /// These dependencies will be checked when this policy is enabled and when any required policy is disabled. + /// + public IEnumerable RequiredPolicies { get; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPostUpdateEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPostUpdateEvent.cs new file mode 100644 index 0000000000..08295bf7fb --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPostUpdateEvent.cs @@ -0,0 +1,18 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +public interface IOnPolicyPostUpdateEvent : IPolicyUpdateEvent +{ + /// + /// Performs side effects after a policy has been upserted. + /// For example, this can be used for cleanup tasks or notifications. + /// + /// The policy save request + /// The policy after it was upserted + /// The policy state before it was updated, if any + public Task ExecutePostUpsertSideEffectAsync( + SavePolicyModel policyRequest, + Policy postUpsertedPolicyState, + Policy? previousPolicyState); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs new file mode 100644 index 0000000000..278a17f35e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs @@ -0,0 +1,17 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; + +public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent +{ + /// + /// Performs side effects before a policy is upserted. + /// For example, this can be used to remove non-compliant users from the organization. + /// + /// The policy save request containing the policy update and metadata + /// The current policy, if any + public Task ExecutePreUpsertSideEffectAsync( + SavePolicyModel policyRequest, + Policy? currentPolicy); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyEventHandlerFactory.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyEventHandlerFactory.cs new file mode 100644 index 0000000000..f44ae867dd --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyEventHandlerFactory.cs @@ -0,0 +1,30 @@ +#nullable enable + +using Bit.Core.AdminConsole.Enums; +using OneOf; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; + +/// +/// Provides policy-specific event handlers used during the save workflow in . +/// +/// +/// Supported handlers: +/// - for dependency checks +/// - for custom validation +/// - for pre-save logic +/// - for post-save logic +/// +public interface IPolicyEventHandlerFactory +{ + /// + /// Gets the event handler for the given policy type and handler interface. + /// + /// Handler type implementing . + /// The policy type to resolve. + /// + /// — the handler if available, or None if not implemented. + /// + OneOf GetHandler(PolicyType policyType) where T : IPolicyUpdateEvent; +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs new file mode 100644 index 0000000000..ded1a14f1a --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs @@ -0,0 +1,11 @@ +using Bit.Core.AdminConsole.Enums; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; + +public interface IPolicyUpdateEvent +{ + /// + /// The policy type that the associated handler will handle. + /// + public PolicyType Type { get; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs new file mode 100644 index 0000000000..6d486e1fa0 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; + +public interface IPolicyValidationEvent : IPolicyUpdateEvent +{ + /// + /// Performs side effects after a policy is validated but before it is saved. + /// For example, this can be used to remove non-compliant users from the organization. + /// Implementation is optional; by default, it will not perform any side effects. + /// + /// The policy save request containing the policy update and metadata + /// The current policy, if any + public Task ValidateAsync( + SavePolicyModel policyRequest, + Policy? currentPolicy); + +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IVNextSavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IVNextSavePolicyCommand.cs new file mode 100644 index 0000000000..93414539bb --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IVNextSavePolicyCommand.cs @@ -0,0 +1,34 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Microsoft.Azure.NotificationHubs.Messaging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; + +/// +/// Handles creating or updating organization policies with validation and side effect execution. +/// +/// +/// Workflow: +/// 1. Validates organization can use policies +/// 2. Validates required and dependent policies +/// 3. Runs policy-specific validation () +/// 4. Executes pre-save logic () +/// 5. Saves the policy +/// 6. Logs the event +/// 7. Executes post-save logic () +/// +public interface IVNextSavePolicyCommand +{ + /// + /// Performs the necessary validations, saves the policy and any side effects + /// + /// Policy data, acting user, and metadata. + /// The saved policy with updated revision and applied changes. + /// + /// Thrown if: + /// - The organization can’t use policies + /// - Dependent policies are missing or block changes + /// - Custom validation fails + /// + Task SaveAsync(SavePolicyModel policyRequest); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/PolicyEventHandlerHandlerFactory.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/PolicyEventHandlerHandlerFactory.cs new file mode 100644 index 0000000000..b1abfb2aaf --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/PolicyEventHandlerHandlerFactory.cs @@ -0,0 +1,33 @@ + +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using OneOf; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents; + +public class PolicyEventHandlerHandlerFactory( + IEnumerable allEventHandlers) : IPolicyEventHandlerFactory +{ + public OneOf GetHandler(PolicyType policyType) where T : IPolicyUpdateEvent + { + var tEventHandlers = allEventHandlers.OfType().ToList(); + + var matchingHandlers = tEventHandlers.Where(h => h.Type == policyType).ToList(); + + if (matchingHandlers.Count > 1) + { + throw new InvalidOperationException( + $"Multiple {nameof(IPolicyUpdateEvent)} handlers of type {typeof(T).Name} found for {nameof(PolicyType)} {policyType}. " + + $"Expected one {typeof(T).Name} handler per {nameof(PolicyType)}."); + } + + var policyTEventHandler = matchingHandlers.SingleOrDefault(); + if (policyTEventHandler is null) + { + return new None(); + } + + return policyTEventHandler; + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlerHandlerFactoryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlerHandlerFactoryTests.cs new file mode 100644 index 0000000000..61d24735b6 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyEventHandlerHandlerFactoryTests.cs @@ -0,0 +1,124 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using OneOf.Types; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; + +public class PolicyEventHandlerHandlerFactoryTests +{ + [Fact] + public void GetHandler_ReturnsHandler_WhenHandlerExists() + { + // Arrange + var expectedHandler = new FakeSingleOrgDependencyEvent(); + var factory = new PolicyEventHandlerHandlerFactory([expectedHandler]); + + // Act + var result = factory.GetHandler(PolicyType.SingleOrg); + + // Assert + Assert.True(result.IsT0); + Assert.Equal(expectedHandler, result.AsT0); + } + + [Fact] + public void GetHandler_ReturnsNone_WhenHandlerDoesNotExist() + { + // Arrange + var factory = new PolicyEventHandlerHandlerFactory([new FakeSingleOrgDependencyEvent()]); + + // Act + var result = factory.GetHandler(PolicyType.RequireSso); + + // Assert + Assert.True(result.IsT1); + Assert.IsType(result.AsT1); + } + + [Fact] + public void GetHandler_ReturnsNone_WhenHandlerTypeDoesNotMatch() + { + // Arrange + var factory = new PolicyEventHandlerHandlerFactory([new FakeSingleOrgDependencyEvent()]); + + // Act + var result = factory.GetHandler(PolicyType.SingleOrg); + + // Assert + Assert.True(result.IsT1); + Assert.IsType(result.AsT1); + } + + [Fact] + public void GetHandler_ReturnsCorrectHandler_WhenMultipleHandlerTypesExist() + { + // Arrange + var dependencyEvent = new FakeSingleOrgDependencyEvent(); + var validationEvent = new FakeSingleOrgValidationEvent(); + var factory = new PolicyEventHandlerHandlerFactory([dependencyEvent, validationEvent]); + + // Act + var dependencyResult = factory.GetHandler(PolicyType.SingleOrg); + var validationResult = factory.GetHandler(PolicyType.SingleOrg); + + // Assert + Assert.True(dependencyResult.IsT0); + Assert.Equal(dependencyEvent, dependencyResult.AsT0); + + Assert.True(validationResult.IsT0); + Assert.Equal(validationEvent, validationResult.AsT0); + } + + [Fact] + public void GetHandler_ReturnsCorrectHandler_WhenMultiplePolicyTypesExist() + { + // Arrange + var singleOrgEvent = new FakeSingleOrgDependencyEvent(); + var requireSsoEvent = new FakeRequireSsoDependencyEvent(); + var factory = new PolicyEventHandlerHandlerFactory([singleOrgEvent, requireSsoEvent]); + + // Act + var singleOrgResult = factory.GetHandler(PolicyType.SingleOrg); + var requireSsoResult = factory.GetHandler(PolicyType.RequireSso); + + // Assert + Assert.True(singleOrgResult.IsT0); + Assert.Equal(singleOrgEvent, singleOrgResult.AsT0); + + Assert.True(requireSsoResult.IsT0); + Assert.Equal(requireSsoEvent, requireSsoResult.AsT0); + } + + [Fact] + public void GetHandler_Throws_WhenDuplicateHandlersExist() + { + // Arrange + var factory = new PolicyEventHandlerHandlerFactory([ + new FakeSingleOrgDependencyEvent(), + new FakeSingleOrgDependencyEvent() + ]); + + // Act & Assert + var exception = Assert.Throws(() => + factory.GetHandler(PolicyType.SingleOrg)); + + Assert.Contains("Multiple IPolicyUpdateEvent handlers of type IEnforceDependentPoliciesEvent found for PolicyType SingleOrg", exception.Message); + Assert.Contains("Expected one IEnforceDependentPoliciesEvent handler per PolicyType", exception.Message); + } + + [Fact] + public void GetHandler_ReturnsNone_WhenNoHandlersProvided() + { + // Arrange + var factory = new PolicyEventHandlerHandlerFactory([]); + + // Act + var result = factory.GetHandler(PolicyType.SingleOrg); + + // Assert + Assert.True(result.IsT1); + Assert.IsType(result.AsT1); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEventFixtures.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEventFixtures.cs new file mode 100644 index 0000000000..4c5b23d6e1 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEventFixtures.cs @@ -0,0 +1,37 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using NSubstitute; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; + +public class FakeSingleOrgDependencyEvent : IEnforceDependentPoliciesEvent +{ + public PolicyType Type => PolicyType.SingleOrg; + public IEnumerable RequiredPolicies => []; +} + +public class FakeRequireSsoDependencyEvent : IEnforceDependentPoliciesEvent +{ + public PolicyType Type => PolicyType.RequireSso; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; +} + +public class FakeVaultTimeoutDependencyEvent : IEnforceDependentPoliciesEvent +{ + public PolicyType Type => PolicyType.MaximumVaultTimeout; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; +} + +public class FakeSingleOrgValidationEvent : IPolicyValidationEvent +{ + public PolicyType Type => PolicyType.SingleOrg; + + public readonly Func> ValidateAsyncMock = Substitute.For>>(); + + public Task ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy) + { + return ValidateAsyncMock(policyRequest, currentPolicy); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs new file mode 100644 index 0000000000..1510042446 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs @@ -0,0 +1,471 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using OneOf.Types; +using Xunit; +using EventType = Bit.Core.Enums.EventType; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; + +public class VNextSavePolicyCommandTests +{ + [Theory, BitAutoData] + public async Task SaveAsync_NewPolicy_Success([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate) + { + // Arrange + var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent(); + fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any(), Arg.Any()).Returns(""); + var sutProvider = SutProviderFactory( + [new FakeSingleOrgDependencyEvent()], + [fakePolicyValidationEvent]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + var newPolicy = new Policy + { + Type = policyUpdate.Type, + OrganizationId = policyUpdate.OrganizationId, + Enabled = false + }; + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([newPolicy]); + + var creationDate = sutProvider.GetDependency().Start; + + // Act + await sutProvider.Sut.SaveAsync(savePolicyModel); + + // Assert + await fakePolicyValidationEvent.ValidateAsyncMock + .Received(1) + .Invoke(Arg.Any(), Arg.Any()); + + await AssertPolicySavedAsync(sutProvider, policyUpdate); + + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(p => + p.CreationDate == creationDate && + p.RevisionDate == creationDate)); + } + + [Theory, BitAutoData] + public async Task SaveAsync_ExistingPolicy_Success( + [PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy currentPolicy) + { + // Arrange + var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent(); + fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any(), Arg.Any()).Returns(""); + var sutProvider = SutProviderFactory( + [new FakeSingleOrgDependencyEvent()], + [fakePolicyValidationEvent]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) + .Returns(currentPolicy); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy]); + + // Act + await sutProvider.Sut.SaveAsync(savePolicyModel); + + // Assert + await fakePolicyValidationEvent.ValidateAsyncMock + .Received(1) + .Invoke(Arg.Any(), currentPolicy); + + await AssertPolicySavedAsync(sutProvider, policyUpdate); + + + var revisionDate = sutProvider.GetDependency().Start; + + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(p => + p.Id == currentPolicy.Id && + p.OrganizationId == currentPolicy.OrganizationId && + p.Type == currentPolicy.Type && + p.CreationDate == currentPolicy.CreationDate && + p.RevisionDate == revisionDate)); + } + + [Fact] + public void Constructor_DuplicatePolicyDependencyEvents_Throws() + { + // Arrange & Act + var exception = Assert.Throws(() => + new VNextSavePolicyCommand( + Substitute.For(), + Substitute.For(), + Substitute.For(), + [new FakeSingleOrgDependencyEvent(), new FakeSingleOrgDependencyEvent()], + Substitute.For(), + Substitute.For())); + + // Assert + Assert.Contains("Duplicate PolicyValidationEvent for SingleOrg policy", exception.Message); + } + + [Theory, BitAutoData] + public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate) + { + // Arrange + var sutProvider = SutProviderFactory(); + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) + .Returns(Task.FromResult(null)); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("Organization not found", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_OrganizationCannotUsePolicies_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate) + { + // Arrange + var sutProvider = SutProviderFactory(); + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) + .Returns(new OrganizationAbility + { + Id = policyUpdate.OrganizationId, + UsePolicies = false + }); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("cannot use policies", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_RequiredPolicyIsNull_Throws( + [PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate) + { + // Arrange + var sutProvider = SutProviderFactory( + [ + new FakeRequireSsoDependencyEvent(), + new FakeSingleOrgDependencyEvent() + ]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + var requireSsoPolicy = new Policy + { + Type = PolicyType.RequireSso, + OrganizationId = policyUpdate.OrganizationId, + Enabled = false + }; + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([requireSsoPolicy]); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("Turn on the Single organization policy because it is required for the Require single sign-on authentication policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_RequiredPolicyNotEnabled_Throws( + [PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy) + { + // Arrange + var sutProvider = SutProviderFactory( + [ + new FakeRequireSsoDependencyEvent(), + new FakeSingleOrgDependencyEvent() + ]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + var requireSsoPolicy = new Policy + { + Type = PolicyType.RequireSso, + OrganizationId = policyUpdate.OrganizationId, + Enabled = false + }; + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([singleOrgPolicy, requireSsoPolicy]); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("Turn on the Single organization policy because it is required for the Require single sign-on authentication policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_RequiredPolicyEnabled_Success( + [PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy) + { + // Arrange + var sutProvider = SutProviderFactory( + [ + new FakeRequireSsoDependencyEvent(), + new FakeSingleOrgDependencyEvent() + ]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + var requireSsoPolicy = new Policy + { + Type = PolicyType.RequireSso, + OrganizationId = policyUpdate.OrganizationId, + Enabled = false + }; + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([singleOrgPolicy, requireSsoPolicy]); + + // Act + await sutProvider.Sut.SaveAsync(savePolicyModel); + + // Assert + await AssertPolicySavedAsync(sutProvider, policyUpdate); + } + + [Theory, BitAutoData] + public async Task SaveAsync_DependentPolicyIsEnabled_Throws( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy currentPolicy, + [Policy(PolicyType.RequireSso)] Policy requireSsoPolicy) + { + // Arrange + var sutProvider = SutProviderFactory( + [ + new FakeRequireSsoDependencyEvent(), + new FakeSingleOrgDependencyEvent() + ]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy, requireSsoPolicy]); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("Turn off the Require single sign-on authentication policy because it requires the Single organization policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_MultipleDependentPoliciesAreEnabled_Throws( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy currentPolicy, + [Policy(PolicyType.RequireSso)] Policy requireSsoPolicy, + [Policy(PolicyType.MaximumVaultTimeout)] Policy vaultTimeoutPolicy) + { + // Arrange + var sutProvider = SutProviderFactory( + [ + new FakeRequireSsoDependencyEvent(), + new FakeSingleOrgDependencyEvent(), + new FakeVaultTimeoutDependencyEvent() + ]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy, requireSsoPolicy, vaultTimeoutPolicy]); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("Turn off all of the policies that require the Single organization policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + [Theory, BitAutoData] + public async Task SaveAsync_DependentPolicyNotEnabled_Success( + [PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy currentPolicy, + [Policy(PolicyType.RequireSso, false)] Policy requireSsoPolicy) + { + // Arrange + var sutProvider = SutProviderFactory( + [ + new FakeRequireSsoDependencyEvent(), + new FakeSingleOrgDependencyEvent() + ]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy, requireSsoPolicy]); + + // Act + await sutProvider.Sut.SaveAsync(savePolicyModel); + + // Assert + await AssertPolicySavedAsync(sutProvider, policyUpdate); + } + + [Theory, BitAutoData] + public async Task SaveAsync_ThrowsOnValidationError([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate) + { + // Arrange + var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent(); + fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any(), Arg.Any()).Returns("Validation error!"); + var sutProvider = SutProviderFactory( + [new FakeSingleOrgDependencyEvent()], + [fakePolicyValidationEvent]); + + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + var singleOrgPolicy = new Policy + { + Type = PolicyType.SingleOrg, + OrganizationId = policyUpdate.OrganizationId, + Enabled = false + }; + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([singleOrgPolicy]); + + // Act + var badRequestException = await Assert.ThrowsAsync( + () => sutProvider.Sut.SaveAsync(savePolicyModel)); + + // Assert + Assert.Contains("Validation error!", badRequestException.Message, StringComparison.OrdinalIgnoreCase); + await AssertPolicyNotSavedAsync(sutProvider); + } + + /// + /// Returns a new SutProvider with the PolicyDependencyEvents registered in the Sut. + /// + private static SutProvider SutProviderFactory( + IEnumerable? policyDependencyEvents = null, + IEnumerable? policyValidationEvents = null) + { + var policyEventHandlerFactory = Substitute.For(); + + // Setup factory to return handlers based on type + policyEventHandlerFactory.GetHandler(Arg.Any()) + .Returns(callInfo => + { + var policyType = callInfo.Arg(); + var handler = policyDependencyEvents?.FirstOrDefault(e => e.Type == policyType); + return handler != null ? OneOf.OneOf.FromT0(handler) : OneOf.OneOf.FromT1(new None()); + }); + + policyEventHandlerFactory.GetHandler(Arg.Any()) + .Returns(callInfo => + { + var policyType = callInfo.Arg(); + var handler = policyValidationEvents?.FirstOrDefault(e => e.Type == policyType); + return handler != null ? OneOf.OneOf.FromT0(handler) : OneOf.OneOf.FromT1(new None()); + }); + + policyEventHandlerFactory.GetHandler(Arg.Any()) + .Returns(new None()); + + policyEventHandlerFactory.GetHandler(Arg.Any()) + .Returns(new None()); + + return new SutProvider() + .WithFakeTimeProvider() + .SetDependency(policyDependencyEvents ?? []) + .SetDependency(policyEventHandlerFactory) + .Create(); + } + + private static void ArrangeOrganization(SutProvider sutProvider, PolicyUpdate policyUpdate) + { + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) + .Returns(new OrganizationAbility + { + Id = policyUpdate.OrganizationId, + UsePolicies = true + }); + } + + private static async Task AssertPolicyNotSavedAsync(SutProvider sutProvider) + { + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .UpsertAsync(default!); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogPolicyEventAsync(default, default); + } + + private static async Task AssertPolicySavedAsync(SutProvider sutProvider, PolicyUpdate policyUpdate) + { + await sutProvider.GetDependency().Received(1).UpsertAsync(ExpectedPolicy()); + + await sutProvider.GetDependency().Received(1) + .LogPolicyEventAsync(ExpectedPolicy(), EventType.Policy_Updated); + + return; + + Policy ExpectedPolicy() => Arg.Is( + p => + p.Type == policyUpdate.Type + && p.OrganizationId == policyUpdate.OrganizationId + && p.Enabled == policyUpdate.Enabled + && p.Data == policyUpdate.Data); + } +} From d722314e2de45b5f1c95965d13cc500bbeb37ca9 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Fri, 10 Oct 2025 12:15:29 -0400 Subject: [PATCH 09/14] [PM-26551] MJML build script (#6417) * docs: update readme for MJML * docs: add readme for MailTemplates directory * feat: add node build script for cross platform build support --- .gitignore | 1 + src/Core/MailTemplates/Mjml/README.md | 109 +++++++++++++-- src/Core/MailTemplates/Mjml/build.js | 128 ++++++++++++++++++ src/Core/MailTemplates/Mjml/build.sh | 4 - .../MailTemplates/Mjml/components/head.mjml | 14 +- .../MailTemplates/Mjml/emails/two-factor.mjml | 4 +- src/Core/MailTemplates/Mjml/package.json | 6 +- src/Core/MailTemplates/README.md | 78 +++++++++++ 8 files changed, 326 insertions(+), 18 deletions(-) create mode 100644 src/Core/MailTemplates/Mjml/build.js delete mode 100755 src/Core/MailTemplates/Mjml/build.sh create mode 100644 src/Core/MailTemplates/README.md diff --git a/.gitignore b/.gitignore index 3b1f40e673..059c94bac3 100644 --- a/.gitignore +++ b/.gitignore @@ -215,6 +215,7 @@ bitwarden_license/src/Sso/wwwroot/assets **/**.swp .mono src/Core/MailTemplates/Mjml/out +src/Core/MailTemplates/Mjml/out-hbs src/Admin/Admin.zip src/Api/Api.zip diff --git a/src/Core/MailTemplates/Mjml/README.md b/src/Core/MailTemplates/Mjml/README.md index b60655140a..7a497252d0 100644 --- a/src/Core/MailTemplates/Mjml/README.md +++ b/src/Core/MailTemplates/Mjml/README.md @@ -1,19 +1,112 @@ -# Email templates +# MJML email templating -This directory contains MJML templates for emails sent by the application. MJML is a markup language designed to reduce the pain of coding responsive email templates. +This directory contains MJML templates for emails. MJML is a markup language designed to reduce the pain of coding responsive email templates. Component based development features in MJML improve code quality and reusability. -## Usage +MJML stands for MailJet Markup Language. -```bash +## Implementation considerations + +These `MJML` templates are compiled into HTML which will then be further consumed by our Handlebars mail service. We can continue to use this service to assign values from our View Models. This leverages the existing infrastructure. It also means we can continue to use the double brace (`{{}}`) syntax within MJML since Handlebars can be used to assign values to those `{{variables}}`. + +There is no change on how we interact with our view models. + +There is an added step where we compile `*.mjml` to `*.html.hbs`. `*.html.hbs` is the format we use so the handlebars service can apply the variables. This build pipeline process is in progress and may need to be manually done at times. + +### `*.txt.hbs` + +There is no change to how we create the `txt.hbs`. MJML does not impact how we create these artifacts. + +## Building `MJML` files + +```shell npm ci -# Build once +# Build *.html to ./out directory npm run build -# To build on changes -npm run watch +# To build on changes to *.mjml and *.js files, new files will not be tracked, you will need to run again +npm run build:watch + +# Build *.html.hbs to ./out directory +npm run build:hbs + +# Build minified *.html.hbs to ./out directory +npm run build:minify + +# apply prettier formatting +npm run prettier ``` ## Development -MJML supports components and you can create your own components by adding them to `.mjmlconfig`. +MJML supports components and you can create your own components by adding them to `.mjmlconfig`. Components are simple JavaScript that return MJML markup based on the attributes assigned, see components/mj-bw-hero.js. The markup is not a proper object, but contained in a string. + +When using MJML templating you can use the above [commands](#building-mjml-files) to compile the template and view it in a web browser. + +Not all MJML tags have the same attributes, it is highly recommended to review the documentation on the official MJML website to understand the usages of each of the tags. + +### Recommended development + +#### Mjml email template development + +1. create `cool-email.mjml` in appropriate team directory +2. run `npm run build:watch` +3. view compiled `HTML` output in a web browser +4. iterate -> while `build:watch`'ing you should be able to refresh the browser page after the mjml/js re-compile to see the changes + +#### Testing with `IMailService` + +After the email is developed from the [initial step](#mjml-email-template-development) make sure the email `{{variables}}` are populated properly by running it through an `IMailService` implementation. + +1. run `npm run build:minify` +2. copy built `*.html.hbs` files from the build directory to a location the mail service can consume them +3. run code that will send the email + +The minified `html.hbs` artifacts are deliverables and must be placed into the correct `src/Core/MailTemplates/Handlebars/` directories in order to be used by `IMailService` implementations. + +### Custom tags + +There is currently a `mj-bw-hero` tag you can use within your `*.mjml` templates. This is a good example of how to create a component that takes in attribute values allowing us to be more DRY in our development of emails. Since the attribute's input is a string we are able to define whatever we need into the component, in this case `mj-bw-hero`. + +In order to view the custom component you have written you will need to include it in the `.mjmlconfig` and reference it in an `mjml` template file. + +```html + + +``` + +Attributes in Custom Components are defined by the developer. They can be required or optional depending on implementation. See the official MJML documentation for more information. + +```js +static allowedAttributes = { + "img-src": "string", // REQUIRED: Source for the image displayed in the right-hand side of the blue header area + title: "string", // REQUIRED: large text stating primary purpose of the email + "button-text": "string", // OPTIONAL: text to display in the button + "button-url": "string", // OPTIONAL: URL to navigate to when the button is clicked + "sub-title": "string", // OPTIONAL: smaller text providing additional context for the title +}; + +static defaultAttributes = {}; +``` + +Custom components, such as `mj-bw-hero`, must be defined in the `.mjmlconfig` in order for them to be compiled and rendered properly in the templates. + +```json +{ + "packages": ["components/mj-bw-hero"] +} +``` + +### `mj-include` + +You are also able to reference other more static MJML templates in your MJML file simply by referencing the file within the MJML template. + +```html + + + + +``` diff --git a/src/Core/MailTemplates/Mjml/build.js b/src/Core/MailTemplates/Mjml/build.js new file mode 100644 index 0000000000..db8a7fe433 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/build.js @@ -0,0 +1,128 @@ +const mjml2html = require("mjml"); +const { registerComponent } = require("mjml-core"); +const fs = require("fs"); +const path = require("path"); +const glob = require("glob"); + +// Parse command line arguments +const args = process.argv.slice(2); // Remove 'node' and script path + +// Parse flags +const flags = { + minify: args.includes("--minify") || args.includes("-m"), + watch: args.includes("--watch") || args.includes("-w"), + hbs: args.includes("--hbs") || args.includes("-h"), + trace: args.includes("--trace") || args.includes("-t"), + clean: args.includes("--clean") || args.includes("-c"), + help: args.includes("--help"), +}; + +// Use __dirname to get absolute paths relative to the script location +const config = { + inputDir: path.join(__dirname, "emails"), + outputDir: path.join(__dirname, "out"), + minify: flags.minify, + validationLevel: "strict", + hbsOutput: flags.hbs, +}; + +// Debug output +if (flags.trace) { + console.log("[DEBUG] Script location:", __dirname); + console.log("[DEBUG] Input directory:", config.inputDir); + console.log("[DEBUG] Output directory:", config.outputDir); +} + +// Ensure output directory exists +if (!fs.existsSync(config.outputDir)) { + fs.mkdirSync(config.outputDir, { recursive: true }); + if (flags.trace) { + console.log("[INFO] Created output directory:", config.outputDir); + } +} + +// Find all MJML files with absolute path +const mjmlFiles = glob.sync(`${config.inputDir}/**/*.mjml`); + +console.log(`\n[INFO] Found ${mjmlFiles.length} MJML file(s) to compile...`); + +if (mjmlFiles.length === 0) { + console.error("[ERROR] No MJML files found!"); + console.error("[ERROR] Looked in:", config.inputDir); + console.error( + "[ERROR] Does this directory exist?", + fs.existsSync(config.inputDir), + ); + process.exit(1); +} + +// Compile each MJML file +let successCount = 0; +let errorCount = 0; + +mjmlFiles.forEach((filePath) => { + try { + const mjmlContent = fs.readFileSync(filePath, "utf8"); + const fileName = path.basename(filePath, ".mjml"); + const relativePath = path.relative(config.inputDir, filePath); + + console.log(`\n[BUILD] Compiling: ${relativePath}`); + + // Compile MJML to HTML + const result = mjml2html(mjmlContent, { + minify: config.minify, + validationLevel: config.validationLevel, + filePath: filePath, // Important: tells MJML where the file is for resolving includes + mjmlConfigPath: __dirname, // Point to the directory with .mjmlconfig + }); + + // Check for errors + if (result.errors.length > 0) { + console.error(`[ERROR] Failed to compile ${fileName}.mjml:`); + result.errors.forEach((err) => + console.error(` ${err.formattedMessage}`), + ); + errorCount++; + return; + } + + // Calculate output path preserving directory structure + const relativeDir = path.dirname(relativePath); + const outputDir = path.join(config.outputDir, relativeDir); + + // Ensure subdirectory exists + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const outputExtension = config.hbsOutput ? ".html.hbs" : ".html"; + const outputPath = path.join(outputDir, `${fileName}${outputExtension}`); + fs.writeFileSync(outputPath, result.html); + + console.log( + `[OK] Built: ${fileName}.mjml → ${path.relative(__dirname, outputPath)}`, + ); + successCount++; + + // Log warnings if any + if (result.warnings && result.warnings.length > 0) { + console.warn(`[WARN] Warnings for ${fileName}.mjml:`); + result.warnings.forEach((warn) => + console.warn(` ${warn.formattedMessage}`), + ); + } + } catch (error) { + console.error(`[ERROR] Exception processing ${path.basename(filePath)}:`); + console.error(` ${error.message}`); + errorCount++; + } +}); + +console.log(`\n[SUMMARY] Compilation complete!`); +console.log(` Success: ${successCount}`); +console.log(` Failed: ${errorCount}`); +console.log(` Output: ${config.outputDir}`); + +if (errorCount > 0) { + process.exit(1); +} diff --git a/src/Core/MailTemplates/Mjml/build.sh b/src/Core/MailTemplates/Mjml/build.sh deleted file mode 100755 index c76bdd8f61..0000000000 --- a/src/Core/MailTemplates/Mjml/build.sh +++ /dev/null @@ -1,4 +0,0 @@ -# TODO: This should probably be replaced with a node script building every file in `emails/` - -npx mjml emails/invite.mjml -o out/invite.html -npx mjml emails/two-factor.mjml -o out/two-factor.html diff --git a/src/Core/MailTemplates/Mjml/components/head.mjml b/src/Core/MailTemplates/Mjml/components/head.mjml index 929057fb70..389ae77c12 100644 --- a/src/Core/MailTemplates/Mjml/components/head.mjml +++ b/src/Core/MailTemplates/Mjml/components/head.mjml @@ -8,9 +8,17 @@ - .link { text-decoration: none; color: #175ddc; font-weight: 600 } + .link { + text-decoration: none; + color: #175ddc; + font-weight: 600; + } - .border-fix > table { border-collapse:separate !important; } .border-fix > - table > tbody > tr > td { border-radius: 3px; } + .border-fix > table { + border-collapse: separate !important; + } + .border-fix > table > tbody > tr > td { + border-radius: 3px; + } diff --git a/src/Core/MailTemplates/Mjml/emails/two-factor.mjml b/src/Core/MailTemplates/Mjml/emails/two-factor.mjml index b959ec1c8a..5091e208d3 100644 --- a/src/Core/MailTemplates/Mjml/emails/two-factor.mjml +++ b/src/Core/MailTemplates/Mjml/emails/two-factor.mjml @@ -15,7 +15,9 @@ -

Your two-step verification code is: {{Token}}

+

+ Your two-step verification code is: {{ Token }} +

Use this code to complete logging in with Bitwarden.

diff --git a/src/Core/MailTemplates/Mjml/package.json b/src/Core/MailTemplates/Mjml/package.json index 8a8f81e845..f74279da7b 100644 --- a/src/Core/MailTemplates/Mjml/package.json +++ b/src/Core/MailTemplates/Mjml/package.json @@ -15,8 +15,10 @@ }, "homepage": "https://bitwarden.com", "scripts": { - "build": "./build.sh", - "watch": "nodemon --exec ./build.sh --watch ./components --watch ./emails --ext js,mjml", + "build": "node ./build.js", + "build:hbs": "node ./build.js --hbs", + "build:minify": "node ./build.js --hbs --minify", + "build:watch": "nodemon ./build.js --watch emails --watch components --ext mjml,js", "prettier": "prettier --cache --write ." }, "dependencies": { diff --git a/src/Core/MailTemplates/README.md b/src/Core/MailTemplates/README.md new file mode 100644 index 0000000000..bd42b2a10f --- /dev/null +++ b/src/Core/MailTemplates/README.md @@ -0,0 +1,78 @@ +Email templating +================ + +We use MJML to generate the HTML that our mail services use to send emails to users. To accomplish this, we use different file types depending on which part of the email generation process we're working with. + +# File Types + +## `*.html.hbs` +These are the compiled HTML email templates that serve as the foundation for all HTML emails sent by the Bitwarden platform. They are generated from MJML source files and enhanced with Handlebars templating capabilities. + +### Generation Process +- **Source**: Built from `*.mjml` files in the `./mjml` directory. + - The MJML source acts as a toolkit for developers to generate HTML. It is the developers responsibility to generate the HTML and then ensure it is accessible to `IMailService` implementations. +- **Build Tool**: Generated via node build scripts: `npm run build`. + - The build script definitions can be viewed in the `Mjml/package.json` as well as in `Mjml/build.js`. +- **Output**: Cross-client compatible HTML with embedded CSS for maximum email client support +- **Template Engine**: Enhanced with Handlebars syntax for dynamic content injection + +### Handlebars Integration +The templates use Handlebars templating syntax for dynamic content replacement: + +```html + +

Welcome {{userName}}!

+

Your organization {{organizationName}} has invited you to join.

+Accept Invitation +``` + +**Variable Types:** +- **Simple Variables**: `{{userName}}`, `{{email}}`, `{{organizationName}}` + +### Email Service Integration +The `IMailService` consumes these templates through the following process: + +1. **Template Selection**: Service selects appropriate `.html.hbs` template based on email type +2. **Model Binding**: View model properties are mapped to Handlebars variables +3. **Compilation**: Handlebars engine processes variables and generates final HTML + +### Development Guidelines + +**Variable Naming:** +- Use camelCase for consistency: `{{userName}}`, `{{organizationName}}` +- Prefix URLs with descriptive names: `{{actionUrl}}`, `{{logoUrl}}` + +**Testing Considerations:** +- Verify Handlebars variable replacement with actual view model data +- Ensure graceful degradation when variables are missing or null, if necessary +- Validate HTML structure and accessibility compliance + +## `*.txt.hbs` +These files provide plain text versions of emails and are essential for email accessibility and deliverability. They serve several important purposes: + +### Purpose and Usage +- **Accessibility**: Screen readers and assistive technologies often work better with plain text versions +- **Email Client Compatibility**: Some email clients prefer or only display plain text versions +- **Fallback Content**: When HTML rendering fails, the plain text version ensures the message is still readable + +### Structure +Plain text email templates use the same Handlebars syntax (`{{variable}}`) as HTML templates for dynamic content replacement. They should: + +- Contain the core message content without HTML formatting +- Use line breaks and spacing for readability +- Include all important links as full URLs +- Maintain logical content hierarchy using spacing and simple text formatting + +### Email Service Integration +The `IMailService` automatically uses both versions when sending emails: +- The HTML version (from `*.html.hbs`) provides rich formatting and styling +- The plain text version (from `*.txt.hbs`) serves as the text alternative +- Email clients can choose which version to display based on user preferences and capabilities + +### Development Guidelines +- Always create a corresponding `*.txt.hbs` file for each `*.html.hbs` template +- Keep the content concise but complete - include all essential information from the HTML version +- Test plain text templates to ensure they're readable and convey the same message + +## `*.mjml` +This is a templating language we use to increase efficiency when creating email content. See the readme within the `./mjml` directory for more comprehensive information. From ac20c84611686906f27cd91739f579eb7be3e5d4 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:29:47 -0700 Subject: [PATCH 10/14] [PM-24747] Remove chromium importer feature flag (#6415) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 80b74877c5..b588ad05b5 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -226,7 +226,6 @@ public static class FeatureFlagKeys /* Tools Team */ public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators"; - public const string UseChromiumImporter = "pm-23982-chromium-importer"; /* Vault Team */ public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; From 6be734bb95bf71f3b24e735a94937dac45d0ce51 Mon Sep 17 00:00:00 2001 From: Hinton Date: Sun, 12 Oct 2025 17:57:35 -0700 Subject: [PATCH 11/14] Get rid of some newlines --- util/DbSeederUtility/Program.cs | 1 - util/Seeder/Factories/UserSeeder.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/util/DbSeederUtility/Program.cs b/util/DbSeederUtility/Program.cs index 8a2c688417..2d75b31934 100644 --- a/util/DbSeederUtility/Program.cs +++ b/util/DbSeederUtility/Program.cs @@ -26,7 +26,6 @@ public class Program // Create service provider with necessary services var services = new ServiceCollection(); ServiceCollectionExtension.ConfigureServices(services); - var serviceProvider = services.BuildServiceProvider(); // Get a scoped DB context diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index d4bcb5eba8..b24f8273b9 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -7,7 +7,6 @@ namespace Bit.Seeder.Factories; public class UserSeeder { - public static User CreateUser(string email) { return new User From 638a6be6ae9d15e9b72f1a9a2cbb670b9ff1ca2f Mon Sep 17 00:00:00 2001 From: Hinton Date: Sun, 12 Oct 2025 17:59:29 -0700 Subject: [PATCH 12/14] Update SDK --- util/RustSdk/rust/Cargo.lock | 269 +++++++++++++++++++++++++++++++---- util/RustSdk/rust/Cargo.toml | 4 +- util/RustSdk/rust/src/lib.rs | 35 ++--- 3 files changed, 259 insertions(+), 49 deletions(-) diff --git a/util/RustSdk/rust/Cargo.lock b/util/RustSdk/rust/Cargo.lock index 1665f387c4..cba06d35ea 100644 --- a/util/RustSdk/rust/Cargo.lock +++ b/util/RustSdk/rust/Cargo.lock @@ -135,8 +135,9 @@ checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" [[package]] name = "bitwarden-api-api" version = "1.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=29c6158636d50141788e41736d15f2f6c7bc7fa8#29c6158636d50141788e41736d15f2f6c7bc7fa8" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" dependencies = [ + "async-trait", "reqwest", "serde", "serde_json", @@ -149,8 +150,9 @@ dependencies = [ [[package]] name = "bitwarden-api-identity" version = "1.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=29c6158636d50141788e41736d15f2f6c7bc7fa8#29c6158636d50141788e41736d15f2f6c7bc7fa8" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" dependencies = [ + "async-trait", "reqwest", "serde", "serde_json", @@ -163,13 +165,13 @@ dependencies = [ [[package]] name = "bitwarden-core" version = "1.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=29c6158636d50141788e41736d15f2f6c7bc7fa8#29c6158636d50141788e41736d15f2f6c7bc7fa8" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" dependencies = [ "async-trait", - "base64", "bitwarden-api-api", "bitwarden-api-identity", "bitwarden-crypto", + "bitwarden-encoding", "bitwarden-error", "bitwarden-state", "bitwarden-uuid", @@ -180,7 +182,7 @@ dependencies = [ "reqwest", "rustls", "rustls-platform-verifier", - "schemars 0.8.22", + "schemars 1.0.4", "serde", "serde_bytes", "serde_json", @@ -194,11 +196,11 @@ dependencies = [ [[package]] name = "bitwarden-crypto" version = "1.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=29c6158636d50141788e41736d15f2f6c7bc7fa8#29c6158636d50141788e41736d15f2f6c7bc7fa8" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" dependencies = [ "aes", "argon2", - "base64", + "bitwarden-encoding", "bitwarden-error", "cbc", "chacha20poly1305", @@ -215,7 +217,7 @@ dependencies = [ "rand_chacha 0.3.1", "rayon", "rsa", - "schemars 0.8.22", + "schemars 1.0.4", "serde", "serde_bytes", "serde_repr", @@ -229,10 +231,21 @@ dependencies = [ "zeroizing-alloc", ] +[[package]] +name = "bitwarden-encoding" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" +dependencies = [ + "data-encoding", + "data-encoding-macro", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "bitwarden-error" version = "1.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=29c6158636d50141788e41736d15f2f6c7bc7fa8#29c6158636d50141788e41736d15f2f6c7bc7fa8" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" dependencies = [ "bitwarden-error-macro", ] @@ -240,7 +253,7 @@ dependencies = [ [[package]] name = "bitwarden-error-macro" version = "1.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=29c6158636d50141788e41736d15f2f6c7bc7fa8#29c6158636d50141788e41736d15f2f6c7bc7fa8" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" dependencies = [ "darling", "proc-macro2", @@ -251,16 +264,38 @@ dependencies = [ [[package]] name = "bitwarden-state" version = "1.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=29c6158636d50141788e41736d15f2f6c7bc7fa8#29c6158636d50141788e41736d15f2f6c7bc7fa8" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" dependencies = [ "async-trait", + "bitwarden-error", + "bitwarden-threading", + "indexed-db", + "js-sys", + "rusqlite", + "serde", + "serde_json", "thiserror 1.0.69", + "tokio", + "tsify", +] + +[[package]] +name = "bitwarden-threading" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" +dependencies = [ + "bitwarden-error", + "log", + "serde", + "thiserror 1.0.69", + "tokio", + "tokio-util", ] [[package]] name = "bitwarden-uuid" version = "1.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=29c6158636d50141788e41736d15f2f6c7bc7fa8#29c6158636d50141788e41736d15f2f6c7bc7fa8" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" dependencies = [ "bitwarden-uuid-macro", ] @@ -268,7 +303,7 @@ dependencies = [ [[package]] name = "bitwarden-uuid-macro" version = "1.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=29c6158636d50141788e41736d15f2f6c7bc7fa8#29c6158636d50141788e41736d15f2f6c7bc7fa8" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" dependencies = [ "quote", "syn", @@ -595,6 +630,32 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "data-encoding-macro" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" +dependencies = [ + "data-encoding", + "syn", +] + [[package]] name = "der" version = "0.7.10" @@ -682,6 +743,18 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -694,6 +767,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -718,6 +797,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -737,9 +827,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -826,6 +918,18 @@ name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.4", +] [[package]] name = "hex" @@ -1088,6 +1192,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexed-db" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f4ecbb6cd50773303683617a93fc2782267d2c94546e9545ec4190eb69aa1a" +dependencies = [ + "futures-channel", + "futures-util", + "pin-project-lite", + "scoped-tls", + "thiserror 2.0.12", + "web-sys", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1195,6 +1313,17 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "litemap" version = "0.8.0" @@ -1413,6 +1542,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "poly1305" version = "0.8.0" @@ -1729,6 +1864,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.25" @@ -1854,20 +2003,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "chrono", - "dyn-clone", - "schemars_derive", - "serde", - "serde_json", - "uuid", -] - [[package]] name = "schemars" version = "0.9.0" @@ -1881,10 +2016,25 @@ dependencies = [ ] [[package]] -name = "schemars_derive" -version = "0.8.22" +name = "schemars" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" dependencies = [ "proc-macro2", "quote", @@ -1892,6 +2042,12 @@ dependencies = [ "syn", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "sdk" version = "0.1.0" @@ -1942,6 +2098,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_bytes" version = "0.11.17" @@ -2283,9 +2450,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-rustls" version = "0.26.2" @@ -2379,6 +2558,30 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tsify" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ec91b85e6c6592ed28636cb1dd1fac377ecbbeb170ff1d79f97aac5e38926d" +dependencies = [ + "serde", + "serde-wasm-bindgen", + "tsify-macros", + "wasm-bindgen", +] + +[[package]] +name = "tsify-macros" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a324606929ad11628a19206d7853807481dcaecd6c08be70a235930b8241955" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "typenum" version = "1.18.0" @@ -2442,6 +2645,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/util/RustSdk/rust/Cargo.toml b/util/RustSdk/rust/Cargo.toml index 47758cee4b..88521277f3 100644 --- a/util/RustSdk/rust/Cargo.toml +++ b/util/RustSdk/rust/Cargo.toml @@ -13,8 +13,8 @@ crate-type = ["cdylib"] [dependencies] base64 = "0.22.1" -bitwarden-core = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "29c6158636d50141788e41736d15f2f6c7bc7fa8" } -bitwarden-crypto = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "29c6158636d50141788e41736d15f2f6c7bc7fa8" } +bitwarden-core = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "1461b3ba6bb6e2d0114770eb4572a1398b4789ef" } +bitwarden-crypto = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "1461b3ba6bb6e2d0114770eb4572a1398b4789ef" } serde = "=1.0.219" serde_json = "=1.0.141" diff --git a/util/RustSdk/rust/src/lib.rs b/util/RustSdk/rust/src/lib.rs index 7c7a628137..10f8d8dca4 100644 --- a/util/RustSdk/rust/src/lib.rs +++ b/util/RustSdk/rust/src/lib.rs @@ -1,10 +1,13 @@ #![allow(clippy::missing_safety_doc)] -use std::ffi::{c_char, CStr, CString}; +use std::{ + ffi::{c_char, CStr, CString}, + num::NonZeroU32, +}; use base64::{engine::general_purpose::STANDARD, Engine}; use bitwarden_crypto::{ - pbkdf2, AsymmetricCryptoKey, AsymmetricPublicCryptoKey, BitwardenLegacyKeyBytes, HashPurpose, + AsymmetricCryptoKey, AsymmetricPublicCryptoKey, BitwardenLegacyKeyBytes, HashPurpose, Kdf, KeyEncryptable, MasterKey, RsaKeyPair, SpkiPublicKeyBytes, SymmetricCryptoKey, UnsignedSharedKey, UserKey, }; @@ -17,11 +20,19 @@ pub unsafe extern "C" fn generate_user_keys( let email = CStr::from_ptr(email).to_str().unwrap(); let password = CStr::from_ptr(password).to_str().unwrap(); - let master_key = derive_master_key(password, email); + println!("Generating keys for {email}"); + println!("Password: {password}"); - let master_password_hash = master_key - .derive_master_key_hash(password.as_bytes(), HashPurpose::ServerAuthorization) - .unwrap(); + let kdf = Kdf::PBKDF2 { + iterations: NonZeroU32::new(5_000).unwrap(), + }; + + let master_key = MasterKey::derive(password, email, &kdf).unwrap(); + + let master_password_hash = + master_key.derive_master_key_hash(password.as_bytes(), HashPurpose::ServerAuthorization); + + println!("Master password hash: {}", master_password_hash); let (user_key, encrypted_user_key) = master_key.make_user_key().unwrap(); @@ -41,14 +52,6 @@ pub unsafe extern "C" fn generate_user_keys( result.into_raw() } -fn derive_master_key(email: &str, password: &str) -> MasterKey { - let mut hash = pbkdf2(password.as_bytes(), email.as_bytes(), 5000); - - let hash = hash.as_mut_slice(); - - hash.try_into().unwrap() -} - fn keypair(key: &SymmetricCryptoKey) -> RsaKeyPair { const RSA_PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCXRVrCX+2hfOQS @@ -82,13 +85,11 @@ XKZBokBGnjFnTnKcs7nv/O8= let private_key = AsymmetricCryptoKey::from_pem(RSA_PRIVATE_KEY).unwrap(); let public_key = private_key.to_public_key().to_der().unwrap(); - let b64 = STANDARD.encode(public_key); - let p = private_key.to_der().unwrap(); RsaKeyPair { private: p.encrypt_with_key(key).unwrap(), - public: b64, + public: public_key.into(), } } From 91badfb2e3f190e70ccc5da98c71e03d2df5430c Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 16 Oct 2025 15:03:01 -0700 Subject: [PATCH 13/14] Remove Sdk2 --- util/Sdk2/dotnet/NativeMethods.cs | 56 ------------------------------- 1 file changed, 56 deletions(-) delete mode 100644 util/Sdk2/dotnet/NativeMethods.cs diff --git a/util/Sdk2/dotnet/NativeMethods.cs b/util/Sdk2/dotnet/NativeMethods.cs deleted file mode 100644 index 32efd38d4f..0000000000 --- a/util/Sdk2/dotnet/NativeMethods.cs +++ /dev/null @@ -1,56 +0,0 @@ -internal static unsafe partial class NativeMethods -{ - // https://docs.microsoft.com/en-us/dotnet/standard/native-interop/cross-platform - // Library path will search - // win => __DllName, __DllName.dll - // linux, osx => __DllName.so, __DllName.dylib - - static NativeMethods() - { - NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, DllImportResolver); - } - - static IntPtr DllImportResolver(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) - { - if (libraryName == __DllName) - { - var path = "runtimes/"; - var extension = ""; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - path += "win-"; - extension = ".dll"; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - path += "osx-"; - extension = ".dylib"; - } - else - { - path += "linux-"; - extension = ".so"; - } - - if (RuntimeInformation.ProcessArchitecture == Architecture.X86) - { - path += "x86"; - } - else if (RuntimeInformation.ProcessArchitecture == Architecture.X64) - { - path += "x64"; - } - else if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64) - { - path += "arm64"; - } - - path += "/native/" + __DllName + extension; - - return NativeLibrary.Load(Path.Combine(AppContext.BaseDirectory, path), assembly, searchPath); - } - - return IntPtr.Zero; - } -} From 685ee37e37944ed16f71a34d5bc4eaba5cd960bf Mon Sep 17 00:00:00 2001 From: Hinton Date: Thu, 16 Oct 2025 15:04:10 -0700 Subject: [PATCH 14/14] Add cargo to renovate --- .github/renovate.json5 | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 5c01832c06..b8ef5704d3 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -2,6 +2,7 @@ $schema: "https://docs.renovatebot.com/renovate-schema.json", extends: ["github>bitwarden/renovate-config"], // Extends our default configuration for pinned dependencies enabledManagers: [ + "cargo", "dockerfile", "docker-compose", "github-actions",