diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 65780bdb63..597085d97d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -36,6 +36,7 @@ util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev # UIF src/Core/MailTemplates/Mjml @bitwarden/team-ui-foundation # Teams are expected to own sub-directories of this project +src/Core/MailTemplates/Mjml/.mjmlconfig # This change allows teams to add components within their own subdirectories without requiring a code review from UIF. # Auth team **/Auth @bitwarden/team-auth-dev diff --git a/.github/ISSUE_TEMPLATE/bw-lite.yml b/.github/ISSUE_TEMPLATE/bw-lite.yml index f46f4b3e37..0c43fa5835 100644 --- a/.github/ISSUE_TEMPLATE/bw-lite.yml +++ b/.github/ISSUE_TEMPLATE/bw-lite.yml @@ -1,4 +1,4 @@ -name: Bitwarden Lite Deployment Bug Report +name: Bitwarden lite Deployment Bug Report description: File a bug report labels: [bug, bw-lite-deploy] body: @@ -70,15 +70,6 @@ body: mariadb:10 # Postgres Example postgres:14 - - type: textarea - id: epic-label - attributes: - label: Issue-Link - description: Link to our pinned issue, tracking all Bitwarden Lite - value: | - https://github.com/bitwarden/server/issues/2480 - validations: - required: true - type: checkboxes id: issue-tracking-info attributes: diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 34af3b7895..6160301aa6 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -42,8 +42,9 @@ dependencyDashboardApproval: false, }, { - matchSourceUrls: ["https://github.com/bitwarden/sdk-internal"], + matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"], groupName: "sdk-internal", + dependencyDashboardApproval: true }, { matchManagers: ["dockerfile", "docker-compose"], @@ -63,7 +64,6 @@ }, { matchPackageNames: [ - "Azure.Extensions.AspNetCore.DataProtection.Blobs", "DuoUniversal", "Fido2.AspNet", "Duende.IdentityServer", @@ -90,11 +90,7 @@ "Microsoft.AspNetCore.Mvc.Testing", "Newtonsoft.Json", "NSubstitute", - "Sentry.Serilog", - "Serilog.AspNetCore", - "Serilog.Extensions.Logging", "Serilog.Extensions.Logging.File", - "Serilog.Sinks.SyslogMessages", "Stripe.net", "Swashbuckle.AspNetCore", "Swashbuckle.AspNetCore.SwaggerGen", @@ -140,6 +136,7 @@ "AspNetCoreRateLimit", "AspNetCoreRateLimit.Redis", "Azure.Data.Tables", + "Azure.Extensions.AspNetCore.DataProtection.Blobs", "Azure.Messaging.EventGrid", "Azure.Messaging.ServiceBus", "Azure.Storage.Blobs", diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 58e9ffa360..9b457b9d56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -185,13 +185,6 @@ jobs: - name: Log in to ACR - production subscription run: az acr login -n bitwardenprod - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" - ########## Generate image tag and build Docker image ########## - name: Generate Docker image tag id: tag @@ -250,8 +243,6 @@ jobs: linux/arm64 push: true tags: ${{ steps.image-tags.outputs.tags }} - secrets: | - "GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}" - name: Install Cosign if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' @@ -280,7 +271,7 @@ jobs: output-format: sarif - name: Upload Grype results to GitHub - uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 + uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4 with: sarif_file: ${{ steps.container-scan.outputs.sarif }} sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }} @@ -479,20 +470,29 @@ jobs: tenant_id: ${{ secrets.AZURE_TENANT_ID }} client_id: ${{ secrets.AZURE_CLIENT_ID }} - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat + - name: Get Azure Key Vault secrets + id: get-kv-secrets uses: bitwarden/gh-actions/get-keyvault-secrets@main with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main - - name: Trigger Bitwarden Lite build + - name: Generate GH App token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + id: app-token + with: + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + owner: ${{ github.repository_owner }} + repositories: self-host + + - name: Trigger Bitwarden lite build uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + github-token: ${{ steps.app-token.outputs.token }} script: | await github.rest.actions.createWorkflowDispatch({ owner: 'bitwarden', @@ -520,20 +520,29 @@ jobs: tenant_id: ${{ secrets.AZURE_TENANT_ID }} client_id: ${{ secrets.AZURE_CLIENT_ID }} - - name: Retrieve GitHub PAT secrets - id: retrieve-secret-pat + - name: Get Azure Key Vault secrets + id: get-kv-secrets uses: bitwarden/gh-actions/get-keyvault-secrets@main with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main + - name: Generate GH App token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + id: app-token + with: + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + owner: ${{ github.repository_owner }} + repositories: devops + - name: Trigger k8s deploy uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + github-token: ${{ steps.app-token.outputs.token }} script: | await github.rest.actions.createWorkflowDispatch({ owner: 'bitwarden', diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 92452102cf..74823c34b5 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -22,9 +22,7 @@ on: required: false type: string -permissions: - pull-requests: write - contents: write +permissions: {} jobs: setup: @@ -32,6 +30,7 @@ jobs: runs-on: ubuntu-24.04 outputs: branch: ${{ steps.set-branch.outputs.branch }} + permissions: {} steps: - name: Set branch id: set-branch @@ -89,6 +88,7 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + permission-contents: write - name: Check out branch uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -212,6 +212,7 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + permission-contents: write - name: Check out target ref uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -240,10 +241,5 @@ jobs: move_edd_db_scripts: name: Move EDD database scripts needs: cut_branch - permissions: - actions: read - contents: write - id-token: write - pull-requests: write + permissions: {} uses: ./.github/workflows/_move_edd_db_scripts.yml - secrets: inherit diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index 2dbe8d02ca..1f192839df 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -62,7 +62,7 @@ jobs: docker compose --profile mssql --profile postgres --profile mysql up -d shell: pwsh - - name: Add MariaDB for Bitwarden Lite + - name: Add MariaDB for Bitwarden lite # Use a different port than MySQL run: | docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10 @@ -133,7 +133,7 @@ jobs: # Default Sqlite BW_TEST_DATABASES__3__TYPE: "Sqlite" BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db" - # Bitwarden Lite MariaDB + # Bitwarden lite MariaDB BW_TEST_DATABASES__4__TYPE: "MySql" BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true" run: dotnet test -- --coverage --coverage-output-format cobertura --coverage-output "coverage.cobertura.xml" --report-xunit-trx --report-xunit-trx-filename "infrastructure-test-results.trx" @@ -262,3 +262,26 @@ jobs: working-directory: "dev" run: docker compose down shell: pwsh + + validate-migration-naming: + name: Validate new migration naming and order + runs-on: ubuntu-22.04 + + steps: + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate new migrations for pull request + if: github.event_name == 'pull_request' + run: | + git fetch origin main:main + pwsh dev/verify_migrations.ps1 -BaseRef main + shell: pwsh + + - name: Validate new migrations for push + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + run: pwsh dev/verify_migrations.ps1 -BaseRef HEAD~1 + shell: pwsh diff --git a/Directory.Build.props b/Directory.Build.props index 66cac5d852..023eeb4cf7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.11.1 + 2025.12.0 Bit.$(MSBuildProjectName) enable @@ -26,7 +26,7 @@ - 17.8.0 + 18.0.1 3.0.1 diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretVersionRepository.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretVersionRepository.cs new file mode 100644 index 0000000000..22421f9921 --- /dev/null +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/SecretVersionRepository.cs @@ -0,0 +1,94 @@ +using AutoMapper; +using Bit.Core.SecretsManager.Repositories; +using Bit.Infrastructure.EntityFramework.Repositories; +using Bit.Infrastructure.EntityFramework.SecretsManager.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Commercial.Infrastructure.EntityFramework.SecretsManager.Repositories; + +public class SecretVersionRepository : Repository, ISecretVersionRepository +{ + public SecretVersionRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) + : base(serviceScopeFactory, mapper, db => db.SecretVersion) + { } + + public override async Task GetByIdAsync(Guid id) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var secretVersion = await dbContext.SecretVersion + .Where(sv => sv.Id == id) + .FirstOrDefaultAsync(); + return Mapper.Map(secretVersion); + } + + public async Task> GetManyBySecretIdAsync(Guid secretId) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var secretVersions = await dbContext.SecretVersion + .Where(sv => sv.SecretId == secretId) + .OrderByDescending(sv => sv.VersionDate) + .ToListAsync(); + return Mapper.Map>(secretVersions); + } + + public async Task> GetManyByIdsAsync(IEnumerable ids) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var versionIds = ids.ToList(); + var secretVersions = await dbContext.SecretVersion + .Where(sv => versionIds.Contains(sv.Id)) + .OrderByDescending(sv => sv.VersionDate) + .ToListAsync(); + return Mapper.Map>(secretVersions); + } + + public override async Task CreateAsync(Core.SecretsManager.Entities.SecretVersion secretVersion) + { + const int maxVersionsToKeep = 10; + + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + // Get the IDs of the most recent (maxVersionsToKeep - 1) versions to keep + var versionsToKeepIds = await dbContext.SecretVersion + .Where(sv => sv.SecretId == secretVersion.SecretId) + .OrderByDescending(sv => sv.VersionDate) + .Take(maxVersionsToKeep - 1) + .Select(sv => sv.Id) + .ToListAsync(); + + // Delete all versions for this secret that are not in the "keep" list + if (versionsToKeepIds.Any()) + { + await dbContext.SecretVersion + .Where(sv => sv.SecretId == secretVersion.SecretId && !versionsToKeepIds.Contains(sv.Id)) + .ExecuteDeleteAsync(); + } + + secretVersion.SetNewId(); + var entity = Mapper.Map(secretVersion); + + await dbContext.AddAsync(entity); + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + + return secretVersion; + } + + public async Task DeleteManyByIdAsync(IEnumerable ids) + { + await using var scope = ServiceScopeFactory.CreateAsyncScope(); + var dbContext = GetDatabaseContext(scope); + + var secretVersionIds = ids.ToList(); + await dbContext.SecretVersion + .Where(sv => secretVersionIds.Contains(sv.Id)) + .ExecuteDeleteAsync(); + } +} diff --git a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/SecretsManagerEFServiceCollectionExtensions.cs b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/SecretsManagerEFServiceCollectionExtensions.cs index d6c8848079..ac52c40ba6 100644 --- a/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/SecretsManagerEFServiceCollectionExtensions.cs +++ b/bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/SecretsManagerEFServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ public static class SecretsManagerEfServiceCollectionExtensions { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); } diff --git a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs index e3c290c85f..88d6858cb8 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs @@ -61,17 +61,15 @@ public class GroupsController : Controller [HttpGet("")] public async Task Get( Guid organizationId, - [FromQuery] string filter, - [FromQuery] int? count, - [FromQuery] int? startIndex) + [FromQuery] GetGroupsQueryParamModel model) { - var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, filter, count, startIndex); + var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, model); var scimListResponseModel = new ScimListResponseModel { Resources = groupsListQueryResult.groupList.Select(g => new ScimGroupResponseModel(g)).ToList(), - ItemsPerPage = count.GetValueOrDefault(groupsListQueryResult.groupList.Count()), + ItemsPerPage = model.Count, TotalResults = groupsListQueryResult.totalResults, - StartIndex = startIndex.GetValueOrDefault(1), + StartIndex = model.StartIndex, }; return Ok(scimListResponseModel); } diff --git a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs index afbfa50bb4..91d79542b5 100644 --- a/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs +++ b/bitwarden_license/src/Scim/Controllers/v2/UsersController.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs b/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs index cc6546700b..f0a561a29f 100644 --- a/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs +++ b/bitwarden_license/src/Scim/Groups/GetGroupsListQuery.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Scim.Groups.Interfaces; +using Bit.Scim.Models; namespace Bit.Scim.Groups; @@ -16,10 +17,16 @@ public class GetGroupsListQuery : IGetGroupsListQuery _groupRepository = groupRepository; } - public async Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex) + public async Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync( + Guid organizationId, GetGroupsQueryParamModel groupQueryParams) { string nameFilter = null; string externalIdFilter = null; + + int count = groupQueryParams.Count; + int startIndex = groupQueryParams.StartIndex; + string filter = groupQueryParams.Filter; + if (!string.IsNullOrWhiteSpace(filter)) { if (filter.StartsWith("displayName eq ")) @@ -53,11 +60,11 @@ public class GetGroupsListQuery : IGetGroupsListQuery } totalResults = groupList.Count; } - else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue) + else if (string.IsNullOrWhiteSpace(filter)) { groupList = groups.OrderBy(g => g.Name) - .Skip(startIndex.Value - 1) - .Take(count.Value) + .Skip(startIndex - 1) + .Take(count) .ToList(); totalResults = groups.Count; } diff --git a/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs b/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs index 07ff044701..4b4ba09e1d 100644 --- a/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs +++ b/bitwarden_license/src/Scim/Groups/Interfaces/IGetGroupsListQuery.cs @@ -1,8 +1,9 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Scim.Models; namespace Bit.Scim.Groups.Interfaces; public interface IGetGroupsListQuery { - Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex); + Task<(IEnumerable groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, GetGroupsQueryParamModel model); } diff --git a/bitwarden_license/src/Scim/Models/GetGroupsQueryParamModel.cs b/bitwarden_license/src/Scim/Models/GetGroupsQueryParamModel.cs new file mode 100644 index 0000000000..5389727917 --- /dev/null +++ b/bitwarden_license/src/Scim/Models/GetGroupsQueryParamModel.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Scim.Models; + +public class GetGroupsQueryParamModel +{ + public string Filter { get; init; } = string.Empty; + + [Range(1, int.MaxValue)] + public int Count { get; init; } = 50; + + [Range(1, int.MaxValue)] + public int StartIndex { get; init; } = 1; +} diff --git a/bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs b/bitwarden_license/src/Scim/Models/GetUsersQueryParamModel.cs similarity index 91% rename from bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs rename to bitwarden_license/src/Scim/Models/GetUsersQueryParamModel.cs index 27d7b6d9a1..cd50dbca61 100644 --- a/bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs +++ b/bitwarden_license/src/Scim/Models/GetUsersQueryParamModel.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; +namespace Bit.Scim.Models; + public class GetUsersQueryParamModel { public string Filter { get; init; } = string.Empty; diff --git a/bitwarden_license/src/Scim/Program.cs b/bitwarden_license/src/Scim/Program.cs index 92f12f59dd..02f2e00d32 100644 --- a/bitwarden_license/src/Scim/Program.cs +++ b/bitwarden_license/src/Scim/Program.cs @@ -11,21 +11,8 @@ public class Program .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); - webBuilder.ConfigureLogging((hostingContext, logging) => - logging.AddSerilog(hostingContext, (e, globalSettings) => - { - var context = e.Properties["SourceContext"].ToString(); - - if (e.Properties.TryGetValue("RequestPath", out var requestPath) && - !string.IsNullOrWhiteSpace(requestPath?.ToString()) && - (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) - { - return false; - } - - return e.Level >= globalSettings.MinLogLevel.ScimSettings.Default; - })); }) + .AddSerilogFileLogging() .Build() .Run(); } diff --git a/bitwarden_license/src/Scim/Startup.cs b/bitwarden_license/src/Scim/Startup.cs index edbbf34aea..2a84faa8dd 100644 --- a/bitwarden_license/src/Scim/Startup.cs +++ b/bitwarden_license/src/Scim/Startup.cs @@ -94,11 +94,8 @@ public class Startup public void Configure( IApplicationBuilder app, IWebHostEnvironment env, - IHostApplicationLifetime appLifetime, GlobalSettings globalSettings) { - app.UseSerilog(env, appLifetime, globalSettings); - // Add general security headers app.UseMiddleware(); diff --git a/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs index a734635ebf..c7085eb6b9 100644 --- a/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs +++ b/bitwarden_license/src/Scim/Users/GetUsersListQuery.cs @@ -3,6 +3,7 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Scim.Models; using Bit.Scim.Users.Interfaces; namespace Bit.Scim.Users; diff --git a/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs b/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs index f584cb8e7b..04133c89eb 100644 --- a/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs +++ b/bitwarden_license/src/Scim/Users/Interfaces/IGetUsersListQuery.cs @@ -1,4 +1,5 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Scim.Models; namespace Bit.Scim.Users.Interfaces; diff --git a/bitwarden_license/src/Scim/Users/PatchUserCommand.cs b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs index 6c983611ee..474557a9cb 100644 --- a/bitwarden_license/src/Scim/Users/PatchUserCommand.cs +++ b/bitwarden_license/src/Scim/Users/PatchUserCommand.cs @@ -1,5 +1,5 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; diff --git a/bitwarden_license/src/Scim/appsettings.Development.json b/bitwarden_license/src/Scim/appsettings.Development.json index 32253a93c1..496d0c075f 100644 --- a/bitwarden_license/src/Scim/appsettings.Development.json +++ b/bitwarden_license/src/Scim/appsettings.Development.json @@ -30,6 +30,7 @@ }, "storage": { "connectionString": "UseDevelopmentStorage=true" - } + }, + "pricingUri": "https://billingpricing.qa.bitwarden.pw" } } diff --git a/bitwarden_license/src/Scim/appsettings.json b/bitwarden_license/src/Scim/appsettings.json index dcdfeb3ede..18b7a7ca7b 100644 --- a/bitwarden_license/src/Scim/appsettings.json +++ b/bitwarden_license/src/Scim/appsettings.json @@ -30,9 +30,6 @@ "connectionString": "SECRET", "applicationCacheTopicName": "SECRET" }, - "sentry": { - "dsn": "SECRET" - }, "notificationHub": { "connectionString": "SECRET", "hubName": "SECRET" diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index bc26fb270a..7141f8429d 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -201,12 +201,15 @@ public class AccountController : Controller returnUrl, state = context.Parameters["state"], userIdentifier = context.Parameters["session_state"], + ssoToken }); } [HttpGet] - public IActionResult ExternalChallenge(string scheme, string returnUrl, string state, string userIdentifier) + public IActionResult ExternalChallenge(string scheme, string returnUrl, string state, string userIdentifier, string ssoToken) { + ValidateSchemeAgainstSsoToken(scheme, ssoToken); + if (string.IsNullOrEmpty(returnUrl)) { returnUrl = "~/"; @@ -235,6 +238,31 @@ public class AccountController : Controller return Challenge(props, scheme); } + /// + /// Validates the scheme (organization ID) against the organization ID found in the ssoToken. + /// + /// The authentication scheme (organization ID) to validate. + /// The SSO token to validate against. + /// Thrown if the scheme (organization ID) does not match the organization ID found in the ssoToken. + private void ValidateSchemeAgainstSsoToken(string scheme, string ssoToken) + { + SsoTokenable tokenable; + + try + { + tokenable = _dataProtector.Unprotect(ssoToken); + } + catch + { + throw new Exception(_i18nService.T("InvalidSsoToken")); + } + + if (!Guid.TryParse(scheme, out var schemeOrgId) || tokenable.OrganizationId != schemeOrgId) + { + throw new Exception(_i18nService.T("SsoOrganizationIdMismatch")); + } + } + [HttpGet] public async Task ExternalCallback() { diff --git a/bitwarden_license/src/Sso/Program.cs b/bitwarden_license/src/Sso/Program.cs index 1a8ce6eb88..bac3bb3d13 100644 --- a/bitwarden_license/src/Sso/Program.cs +++ b/bitwarden_license/src/Sso/Program.cs @@ -1,5 +1,4 @@ using Bit.Core.Utilities; -using Serilog; namespace Bit.Sso; @@ -13,19 +12,8 @@ public class Program .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); - webBuilder.ConfigureLogging((hostingContext, logging) => - logging.AddSerilog(hostingContext, (e, globalSettings) => - { - var context = e.Properties["SourceContext"].ToString(); - if (e.Properties.TryGetValue("RequestPath", out var requestPath) && - !string.IsNullOrWhiteSpace(requestPath?.ToString()) && - (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) - { - return false; - } - return e.Level >= globalSettings.MinLogLevel.SsoSettings.Default; - })); }) + .AddSerilogFileLogging() .Build() .Run(); } diff --git a/bitwarden_license/src/Sso/Startup.cs b/bitwarden_license/src/Sso/Startup.cs index 3ae8883ac4..2f83f3dad0 100644 --- a/bitwarden_license/src/Sso/Startup.cs +++ b/bitwarden_license/src/Sso/Startup.cs @@ -100,8 +100,6 @@ public class Startup IdentityModelEventSource.ShowPII = true; } - app.UseSerilog(env, appLifetime, globalSettings); - // Add general security headers app.UseMiddleware(); diff --git a/bitwarden_license/src/Sso/appsettings.Development.json b/bitwarden_license/src/Sso/appsettings.Development.json index 8aae281068..8e24d82528 100644 --- a/bitwarden_license/src/Sso/appsettings.Development.json +++ b/bitwarden_license/src/Sso/appsettings.Development.json @@ -24,6 +24,13 @@ "storage": { "connectionString": "UseDevelopmentStorage=true" }, - "developmentDirectory": "../../../dev" + "developmentDirectory": "../../../dev", + "pricingUri": "https://billingpricing.qa.bitwarden.pw", + "mail": { + "smtp": { + "host": "localhost", + "port": 10250 + } + } } } diff --git a/bitwarden_license/src/Sso/appsettings.json b/bitwarden_license/src/Sso/appsettings.json index 73c85044cc..9a5df42f7f 100644 --- a/bitwarden_license/src/Sso/appsettings.json +++ b/bitwarden_license/src/Sso/appsettings.json @@ -13,7 +13,11 @@ "mail": { "sendGridApiKey": "SECRET", "amazonConfigSetName": "Email", - "replyToEmail": "no-reply@bitwarden.com" + "replyToEmail": "no-reply@bitwarden.com", + "smtp": { + "host": "localhost", + "port": 10250 + } }, "identityServer": { "certificateThumbprint": "SECRET" diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs index 2bb02c3cee..b367b17c73 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -13,7 +13,7 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Utilities; +using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -207,7 +207,7 @@ public class RemoveOrganizationFromProviderCommandTests organization.PlanType = PlanType.TeamsMonthly; - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly); sutProvider.GetDependency().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); @@ -296,7 +296,7 @@ public class RemoveOrganizationFromProviderCommandTests organization.PlanType = PlanType.TeamsMonthly; - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly); sutProvider.GetDependency().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); @@ -416,7 +416,7 @@ public class RemoveOrganizationFromProviderCommandTests organization.PlanType = PlanType.TeamsMonthly; organization.Enabled = false; // Start with a disabled organization - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly); sutProvider.GetDependency().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index e61cf5f97e..78376f6d98 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -20,6 +20,7 @@ using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.AutoFixture.OrganizationFixtures; +using Bit.Core.Test.Billing.Mocks; using Bit.Core.Tokens; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; @@ -811,12 +812,12 @@ public class ProviderServiceTests organization.Plan = "Enterprise (Monthly)"; sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType) - .Returns(StaticStore.GetPlan(organization.PlanType)); + .Returns(MockPlans.Get(organization.PlanType)); var expectedPlanType = PlanType.EnterpriseMonthly2020; sutProvider.GetDependency().GetPlanOrThrow(expectedPlanType) - .Returns(StaticStore.GetPlan(expectedPlanType)); + .Returns(MockPlans.Get(expectedPlanType)); var expectedPlanId = "2020-enterprise-org-seat-monthly"; diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs index ec52650097..c893886083 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs @@ -18,6 +18,7 @@ using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Test.Billing.Mocks; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.DataProtection; @@ -72,7 +73,7 @@ public class BusinessUnitConverterTests { organization.PlanType = PlanType.EnterpriseAnnually2020; - var enterpriseAnnually2020 = StaticStore.GetPlan(PlanType.EnterpriseAnnually2020); + var enterpriseAnnually2020 = MockPlans.Get(PlanType.EnterpriseAnnually2020); var subscription = new Subscription { @@ -134,7 +135,7 @@ public class BusinessUnitConverterTests _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020) .Returns(enterpriseAnnually2020); - var enterpriseAnnually = StaticStore.GetPlan(PlanType.EnterpriseAnnually); + var enterpriseAnnually = MockPlans.Get(PlanType.EnterpriseAnnually); _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually) .Returns(enterpriseAnnually); @@ -242,7 +243,7 @@ public class BusinessUnitConverterTests argument.Status == ProviderStatusType.Pending && argument.Type == ProviderType.BusinessUnit)).Returns(provider); - var plan = StaticStore.GetPlan(organization.PlanType); + var plan = MockPlans.Get(organization.PlanType); _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index 18c71364e6..daf35e7ae9 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -22,7 +22,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Utilities; +using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Braintree; @@ -140,7 +140,7 @@ public class ProviderBillingServiceTests .Returns(existingPlan); sutProvider.GetDependency().GetPlanOrThrow(existingPlan.PlanType) - .Returns(StaticStore.GetPlan(existingPlan.PlanType)); + .Returns(MockPlans.Get(existingPlan.PlanType)); sutProvider.GetDependency().GetSubscriptionOrThrow(provider) .Returns(new Subscription @@ -155,7 +155,7 @@ public class ProviderBillingServiceTests Id = "si_ent_annual", Price = new Price { - Id = StaticStore.GetPlan(PlanType.EnterpriseAnnually).PasswordManager + Id = MockPlans.Get(PlanType.EnterpriseAnnually).PasswordManager .StripeProviderPortalSeatPlanId }, Quantity = 10 @@ -168,7 +168,7 @@ public class ProviderBillingServiceTests new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly); sutProvider.GetDependency().GetPlanOrThrow(command.NewPlan) - .Returns(StaticStore.GetPlan(command.NewPlan)); + .Returns(MockPlans.Get(command.NewPlan)); // Act await sutProvider.Sut.ChangePlan(command); @@ -185,7 +185,7 @@ public class ProviderBillingServiceTests Arg.Is(p => p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1)); - var newPlanCfg = StaticStore.GetPlan(command.NewPlan); + var newPlanCfg = MockPlans.Get(command.NewPlan); await stripeAdapter.Received(1) .SubscriptionUpdateAsync( Arg.Is(provider.GatewaySubscriptionId), @@ -491,7 +491,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); @@ -514,7 +514,7 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetSubscriptionOrThrow(provider).Returns(subscription); // 50 seats currently assigned with a seat minimum of 100 - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly); sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( [ @@ -573,7 +573,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } var providerPlan = providerPlans.First(); @@ -598,7 +598,7 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetSubscriptionOrThrow(provider).Returns(subscription); // 95 seats currently assigned with a seat minimum of 100 - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly); sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( [ @@ -661,7 +661,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } var providerPlan = providerPlans.First(); @@ -686,7 +686,7 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetSubscriptionOrThrow(provider).Returns(subscription); // 110 seats currently assigned with a seat minimum of 100 - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly); sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( [ @@ -749,7 +749,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } var providerPlan = providerPlans.First(); @@ -774,7 +774,7 @@ public class ProviderBillingServiceTests sutProvider.GetDependency().GetSubscriptionOrThrow(provider).Returns(subscription); // 110 seats currently assigned with a seat minimum of 100 - var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); + var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly); sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( [ @@ -827,13 +827,13 @@ public class ProviderBillingServiceTests } ]); - sutProvider.GetDependency().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType)); + sutProvider.GetDependency().GetPlanOrThrow(planType).Returns(MockPlans.Get(planType)); sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( [ new ProviderOrganizationOrganizationDetails { - Plan = StaticStore.GetPlan(planType).Name, + Plan = MockPlans.Get(planType).Name, Status = OrganizationStatusType.Managed, Seats = 5 } @@ -865,13 +865,13 @@ public class ProviderBillingServiceTests } ]); - sutProvider.GetDependency().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType)); + sutProvider.GetDependency().GetPlanOrThrow(planType).Returns(MockPlans.Get(planType)); sutProvider.GetDependency().GetManyDetailsByProviderAsync(provider.Id).Returns( [ new ProviderOrganizationOrganizationDetails { - Plan = StaticStore.GetPlan(planType).Name, + Plan = MockPlans.Get(planType).Name, Status = OrganizationStatusType.Managed, Seats = 15 } @@ -1238,7 +1238,7 @@ public class ProviderBillingServiceTests .Returns(providerPlans); sutProvider.GetDependency().GetPlanOrThrow(PlanType.EnterpriseMonthly) - .Returns(StaticStore.GetPlan(PlanType.EnterpriseMonthly)); + .Returns(MockPlans.Get(PlanType.EnterpriseMonthly)); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); @@ -1266,7 +1266,7 @@ public class ProviderBillingServiceTests .Returns(providerPlans); sutProvider.GetDependency().GetPlanOrThrow(PlanType.TeamsMonthly) - .Returns(StaticStore.GetPlan(PlanType.TeamsMonthly)); + .Returns(MockPlans.Get(PlanType.TeamsMonthly)); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); @@ -1317,7 +1317,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } sutProvider.GetDependency().GetByProviderId(provider.Id) @@ -1373,7 +1373,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } sutProvider.GetDependency().GetByProviderId(provider.Id) @@ -1449,7 +1449,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } sutProvider.GetDependency().GetByProviderId(provider.Id) @@ -1525,7 +1525,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } sutProvider.GetDependency().GetByProviderId(provider.Id) @@ -1626,7 +1626,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } sutProvider.GetDependency().GetByProviderId(provider.Id) @@ -1704,7 +1704,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } sutProvider.GetDependency().GetByProviderId(provider.Id) @@ -1772,8 +1772,8 @@ public class ProviderBillingServiceTests const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; - var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; - var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var subscription = new Subscription { @@ -1806,7 +1806,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); @@ -1852,8 +1852,8 @@ public class ProviderBillingServiceTests const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; - var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; - var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var subscription = new Subscription { @@ -1886,7 +1886,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); @@ -1932,8 +1932,8 @@ public class ProviderBillingServiceTests const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; - var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; - var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var subscription = new Subscription { @@ -1966,7 +1966,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); @@ -2006,8 +2006,8 @@ public class ProviderBillingServiceTests const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; - var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; - var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var subscription = new Subscription { @@ -2040,7 +2040,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); @@ -2086,8 +2086,8 @@ public class ProviderBillingServiceTests const string enterpriseLineItemId = "enterprise_line_item_id"; const string teamsLineItemId = "teams_line_item_id"; - var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; - var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; + var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var subscription = new Subscription { @@ -2120,7 +2120,7 @@ public class ProviderBillingServiceTests foreach (var plan in providerPlans) { sutProvider.GetDependency().GetPlanOrThrow(plan.PlanType) - .Returns(StaticStore.GetPlan(plan.PlanType)); + .Returns(MockPlans.Get(plan.PlanType)); } providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs index 16ae8f7f2c..776403fdd5 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs @@ -6,7 +6,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Repositories; using Bit.Core.Settings; -using Bit.Core.Utilities; +using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -69,7 +69,7 @@ public class MaxProjectsQueryTests sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency().GetPlan(organization.PlanType) - .Returns(StaticStore.GetPlan(organization.PlanType)); + .Returns(MockPlans.Get(organization.PlanType)); var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); @@ -114,7 +114,7 @@ public class MaxProjectsQueryTests .Returns(projects); sutProvider.GetDependency().GetPlan(organization.PlanType) - .Returns(StaticStore.GetPlan(organization.PlanType)); + .Returns(MockPlans.Get(organization.PlanType)); var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd); diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Repositories/SecretVersionRepositoryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Repositories/SecretVersionRepositoryTests.cs new file mode 100644 index 0000000000..659a6d1233 --- /dev/null +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Repositories/SecretVersionRepositoryTests.cs @@ -0,0 +1,130 @@ +using Bit.Core.SecretsManager.Entities; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Commercial.Core.Test.SecretsManager.Repositories; + +public class SecretVersionRepositoryTests +{ + [Theory] + [BitAutoData] + public void SecretVersion_EntityCreation_Success(SecretVersion secretVersion) + { + // Arrange & Act + secretVersion.SetNewId(); + + // Assert + Assert.NotEqual(Guid.Empty, secretVersion.Id); + Assert.NotEqual(Guid.Empty, secretVersion.SecretId); + Assert.NotNull(secretVersion.Value); + Assert.NotEqual(default, secretVersion.VersionDate); + } + + [Theory] + [BitAutoData] + public void SecretVersion_WithServiceAccountEditor_Success(SecretVersion secretVersion, Guid serviceAccountId) + { + // Arrange & Act + secretVersion.EditorServiceAccountId = serviceAccountId; + secretVersion.EditorOrganizationUserId = null; + + // Assert + Assert.Equal(serviceAccountId, secretVersion.EditorServiceAccountId); + Assert.Null(secretVersion.EditorOrganizationUserId); + } + + [Theory] + [BitAutoData] + public void SecretVersion_WithOrganizationUserEditor_Success(SecretVersion secretVersion, Guid organizationUserId) + { + // Arrange & Act + secretVersion.EditorOrganizationUserId = organizationUserId; + secretVersion.EditorServiceAccountId = null; + + // Assert + Assert.Equal(organizationUserId, secretVersion.EditorOrganizationUserId); + Assert.Null(secretVersion.EditorServiceAccountId); + } + + [Theory] + [BitAutoData] + public void SecretVersion_NullableEditors_Success(SecretVersion secretVersion) + { + // Arrange & Act + secretVersion.EditorServiceAccountId = null; + secretVersion.EditorOrganizationUserId = null; + + // Assert + Assert.Null(secretVersion.EditorServiceAccountId); + Assert.Null(secretVersion.EditorOrganizationUserId); + } + + [Theory] + [BitAutoData] + public void SecretVersion_VersionDateSet_Success(SecretVersion secretVersion) + { + // Arrange + var versionDate = DateTime.UtcNow; + + // Act + secretVersion.VersionDate = versionDate; + + // Assert + Assert.Equal(versionDate, secretVersion.VersionDate); + } + + [Theory] + [BitAutoData] + public void SecretVersion_ValueEncrypted_Success(SecretVersion secretVersion, string encryptedValue) + { + // Arrange & Act + secretVersion.Value = encryptedValue; + + // Assert + Assert.Equal(encryptedValue, secretVersion.Value); + Assert.NotEmpty(secretVersion.Value); + } + + [Theory] + [BitAutoData] + public void SecretVersion_MultipleVersions_DifferentIds(List secretVersions, Guid secretId) + { + // Arrange & Act + foreach (var version in secretVersions) + { + version.SecretId = secretId; + version.SetNewId(); + } + + // Assert + var distinctIds = secretVersions.Select(v => v.Id).Distinct(); + Assert.Equal(secretVersions.Count, distinctIds.Count()); + Assert.All(secretVersions, v => Assert.Equal(secretId, v.SecretId)); + } + + [Theory] + [BitAutoData] + public void SecretVersion_VersionDateOrdering_Success(SecretVersion version1, SecretVersion version2, SecretVersion version3, Guid secretId) + { + // Arrange + var now = DateTime.UtcNow; + version1.SecretId = secretId; + version1.VersionDate = now.AddDays(-2); + + version2.SecretId = secretId; + version2.VersionDate = now.AddDays(-1); + + version3.SecretId = secretId; + version3.VersionDate = now; + + var versions = new List { version2, version3, version1 }; + + // Act + var orderedVersions = versions.OrderByDescending(v => v.VersionDate).ToList(); + + // Assert + Assert.Equal(version3.Id, orderedVersions[0].Id); // Most recent + Assert.Equal(version2.Id, orderedVersions[1].Id); + Assert.Equal(version1.Id, orderedVersions[2].Id); // Oldest + } +} diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index aeba01fa80..a7ff2f682b 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.Registration; @@ -10,6 +11,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Tokens; using Bit.Sso.Controllers; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -1136,4 +1138,129 @@ public class AccountControllerTest Assert.NotNull(result.user); Assert.Equal(email, result.user.Email); } + + [Theory, BitAutoData] + public void ExternalChallenge_WithMatchingOrgId_Succeeds( + SutProvider sutProvider, + Organization organization) + { + // Arrange + var orgId = organization.Id; + var scheme = orgId.ToString(); + var returnUrl = "~/vault"; + var state = "test-state"; + var userIdentifier = "user-123"; + var ssoToken = "valid-sso-token"; + + // Mock the data protector to return a tokenable with matching org ID + var dataProtector = sutProvider.GetDependency>(); + var tokenable = new SsoTokenable(organization, 3600); + dataProtector.Unprotect(ssoToken).Returns(tokenable); + + // Mock URL helper for IsLocalUrl check + var urlHelper = Substitute.For(); + urlHelper.IsLocalUrl(returnUrl).Returns(true); + sutProvider.Sut.Url = urlHelper; + + // Mock interaction service for IsValidReturnUrl check + var interactionService = sutProvider.GetDependency(); + interactionService.IsValidReturnUrl(returnUrl).Returns(true); + + // Act + var result = sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken); + + // Assert + var challengeResult = Assert.IsType(result); + Assert.Contains(scheme, challengeResult.AuthenticationSchemes); + Assert.NotNull(challengeResult.Properties); + Assert.Equal(scheme, challengeResult.Properties.Items["scheme"]); + Assert.Equal(returnUrl, challengeResult.Properties.Items["return_url"]); + Assert.Equal(state, challengeResult.Properties.Items["state"]); + Assert.Equal(userIdentifier, challengeResult.Properties.Items["user_identifier"]); + } + + [Theory, BitAutoData] + public void ExternalChallenge_WithMismatchedOrgId_ThrowsSsoOrganizationIdMismatch( + SutProvider sutProvider, + Organization organization) + { + // Arrange + var correctOrgId = organization.Id; + var wrongOrgId = Guid.NewGuid(); + var scheme = wrongOrgId.ToString(); // Different from tokenable's org ID + var returnUrl = "~/vault"; + var state = "test-state"; + var userIdentifier = "user-123"; + var ssoToken = "valid-sso-token"; + + // Mock the data protector to return a tokenable with different org ID + var dataProtector = sutProvider.GetDependency>(); + var tokenable = new SsoTokenable(organization, 3600); // Contains correctOrgId + dataProtector.Unprotect(ssoToken).Returns(tokenable); + + // Mock i18n service to return the key + sutProvider.GetDependency() + .T(Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // Act & Assert + var ex = Assert.Throws(() => + sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken)); + Assert.Equal("SsoOrganizationIdMismatch", ex.Message); + } + + [Theory, BitAutoData] + public void ExternalChallenge_WithInvalidSchemeFormat_ThrowsSsoOrganizationIdMismatch( + SutProvider sutProvider, + Organization organization) + { + // Arrange + var scheme = "not-a-valid-guid"; + var returnUrl = "~/vault"; + var state = "test-state"; + var userIdentifier = "user-123"; + var ssoToken = "valid-sso-token"; + + // Mock the data protector to return a valid tokenable + var dataProtector = sutProvider.GetDependency>(); + var tokenable = new SsoTokenable(organization, 3600); + dataProtector.Unprotect(ssoToken).Returns(tokenable); + + // Mock i18n service to return the key + sutProvider.GetDependency() + .T(Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // Act & Assert + var ex = Assert.Throws(() => + sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken)); + Assert.Equal("SsoOrganizationIdMismatch", ex.Message); + } + + [Theory, BitAutoData] + public void ExternalChallenge_WithInvalidSsoToken_ThrowsInvalidSsoToken( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var scheme = orgId.ToString(); + var returnUrl = "~/vault"; + var state = "test-state"; + var userIdentifier = "user-123"; + var ssoToken = "invalid-corrupted-token"; + + // Mock the data protector to throw when trying to unprotect + var dataProtector = sutProvider.GetDependency>(); + dataProtector.Unprotect(ssoToken).Returns(_ => throw new Exception("Token validation failed")); + + // Mock i18n service to return the key + sutProvider.GetDependency() + .T(Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // Act & Assert + var ex = Assert.Throws(() => + sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken)); + Assert.Equal("InvalidSsoToken", ex.Message); + } } diff --git a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs index f276515f54..351c56cd8f 100644 --- a/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs +++ b/bitwarden_license/test/Scim.IntegrationTest/Controllers/v2/GroupsControllerTests.cs @@ -200,6 +200,38 @@ public class GroupsControllerTests : IClassFixture, IAsy AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); } + [Fact] + public async Task GetList_SearchDisplayNameWithoutOptionalParameters_Success() + { + string filter = "displayName eq Test Group 2"; + int? itemsPerPage = null; + int? startIndex = null; + var expectedResponse = new ScimListResponseModel + { + ItemsPerPage = 50, //default value + TotalResults = 1, + StartIndex = 1, //default value + Resources = new List + { + new ScimGroupResponseModel + { + Id = ScimApplicationFactory.TestGroupId2, + DisplayName = "Test Group 2", + ExternalId = "B", + Schemas = new List { ScimConstants.Scim2SchemaGroup } + } + }, + Schemas = new List { ScimConstants.Scim2SchemaListResponse } + }; + + var context = await _factory.GroupsGetListAsync(ScimApplicationFactory.TestOrganizationId1, filter, itemsPerPage, startIndex); + + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + + var responseModel = JsonSerializer.Deserialize>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + AssertHelper.AssertPropertyEqual(expectedResponse, responseModel); + } + [Fact] public async Task Post_Success() { diff --git a/bitwarden_license/test/Scim.IntegrationTest/appsettings.Development.json b/bitwarden_license/test/Scim.IntegrationTest/appsettings.Development.json new file mode 100644 index 0000000000..496d0c075f --- /dev/null +++ b/bitwarden_license/test/Scim.IntegrationTest/appsettings.Development.json @@ -0,0 +1,36 @@ +{ + "globalSettings": { + "baseServiceUri": { + "vault": "https://localhost:8080", + "api": "http://localhost:4000", + "identity": "http://localhost:33656", + "admin": "http://localhost:62911", + "notifications": "http://localhost:61840", + "sso": "http://localhost:51822", + "internalNotifications": "http://localhost:61840", + "internalAdmin": "http://localhost:62911", + "internalIdentity": "http://localhost:33656", + "internalApi": "http://localhost:4000", + "internalVault": "https://localhost:8080", + "internalSso": "http://localhost:51822", + "internalScim": "http://localhost:44559" + }, + "mail": { + "smtp": { + "host": "localhost", + "port": 10250 + } + }, + "attachment": { + "connectionString": "UseDevelopmentStorage=true", + "baseUrl": "http://localhost:4000/attachments/" + }, + "events": { + "connectionString": "UseDevelopmentStorage=true" + }, + "storage": { + "connectionString": "UseDevelopmentStorage=true" + }, + "pricingUri": "https://billingpricing.qa.bitwarden.pw" + } +} diff --git a/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs b/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs index 1599b6e390..b835e1fe6b 100644 --- a/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs +++ b/bitwarden_license/test/Scim.Test/Groups/GetGroupsListQueryTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Scim.Groups; +using Bit.Scim.Models; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; @@ -24,7 +25,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, null, count, startIndex); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Count = count, StartIndex = startIndex }); AssertHelper.AssertPropertyEqual(groups.Skip(startIndex - 1).Take(count).ToList(), result.groupList); AssertHelper.AssertPropertyEqual(groups.Count, result.totalResults); @@ -47,7 +48,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter }); AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); @@ -67,7 +68,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter }); AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); @@ -90,7 +91,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter }); AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); @@ -112,7 +113,7 @@ public class GetGroupsListCommandTests .GetManyByOrganizationIdAsync(organizationId) .Returns(groups); - var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null); + var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter }); AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList); AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults); diff --git a/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs b/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs index 9352e5c202..7424b50c0d 100644 --- a/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/GetUsersListQueryTests.cs @@ -1,5 +1,6 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Scim.Models; using Bit.Scim.Users; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; diff --git a/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs index f391c93fe3..8b6c850c6f 100644 --- a/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs +++ b/bitwarden_license/test/Scim.Test/Users/PatchUserCommandTests.cs @@ -1,6 +1,6 @@ using System.Text.Json; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/dev/generate_openapi_files.ps1 b/dev/generate_openapi_files.ps1 index 9eca7dc734..011319b3a3 100644 --- a/dev/generate_openapi_files.ps1 +++ b/dev/generate_openapi_files.ps1 @@ -18,11 +18,11 @@ if ($LASTEXITCODE -ne 0) { # Api internal & public Set-Location "../../src/Api" dotnet build -dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal" +dotnet swagger tofile --output "../../api.json" "./bin/Debug/net8.0/Api.dll" "internal" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public" +dotnet swagger tofile --output "../../api.public.json" "./bin/Debug/net8.0/Api.dll" "public" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/dev/verify_migrations.ps1 b/dev/verify_migrations.ps1 new file mode 100644 index 0000000000..d63c34f2bd --- /dev/null +++ b/dev/verify_migrations.ps1 @@ -0,0 +1,132 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Validates that new database migration files follow naming conventions and chronological order. + +.DESCRIPTION + This script validates migration files in util/Migrator/DbScripts/ to ensure: + 1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql + 2. New migrations are chronologically ordered (filename sorts after existing migrations) + 3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5) + 4. A 2-digit sequence number is included (e.g., _00, _01) + +.PARAMETER BaseRef + The base git reference to compare against (e.g., 'main', 'HEAD~1') + +.PARAMETER CurrentRef + The current git reference (defaults to 'HEAD') + +.EXAMPLE + # For pull requests - compare against main branch + .\verify_migrations.ps1 -BaseRef main + +.EXAMPLE + # For pushes - compare against previous commit + .\verify_migrations.ps1 -BaseRef HEAD~1 +#> + +param( + [Parameter(Mandatory = $true)] + [string]$BaseRef, + + [Parameter(Mandatory = $false)] + [string]$CurrentRef = "HEAD" +) + +# Use invariant culture for consistent string comparison +[System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::InvariantCulture + +$migrationPath = "util/Migrator/DbScripts" + +# Get list of migrations from base reference +try { + $baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/*.sql" 2>$null | Sort-Object + if ($LASTEXITCODE -ne 0) { + Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'" + $baseMigrations = @() + } +} +catch { + Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'" + $baseMigrations = @() +} + +# Get list of migrations from current reference +$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/*.sql" | Sort-Object + +# Find added migrations +$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations } + +if ($addedMigrations.Count -eq 0) { + Write-Host "No new migration files added." + exit 0 +} + +Write-Host "New migration files detected:" +$addedMigrations | ForEach-Object { Write-Host " $_" } +Write-Host "" + +# Get the last migration from base reference +if ($baseMigrations.Count -eq 0) { + Write-Host "No previous migrations found (initial commit?). Skipping validation." + exit 0 +} + +$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1) +Write-Host "Last migration in base reference: $lastBaseMigration" +Write-Host "" + +# Required format regex: YYYY-MM-DD_NN_Description.sql +$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$' + +$validationFailed = $false + +foreach ($migration in $addedMigrations) { + $migrationName = Split-Path -Leaf $migration + + # Validate NEW migration filename format + if ($migrationName -notmatch $formatRegex) { + Write-Host "ERROR: Migration '$migrationName' does not match required format" + Write-Host "Required format: YYYY-MM-DD_NN_Description.sql" + Write-Host " - YYYY: 4-digit year" + Write-Host " - MM: 2-digit month with leading zero (01-12)" + Write-Host " - DD: 2-digit day with leading zero (01-31)" + Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)" + Write-Host "Example: 2025-01-15_00_MyMigration.sql" + $validationFailed = $true + continue + } + + # Compare migration name with last base migration (using ordinal string comparison) + if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) { + Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'" + $validationFailed = $true + } + else { + Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'" + } +} + +Write-Host "" + +if ($validationFailed) { + Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order" + Write-Host "" + Write-Host "All new migration files must:" + Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql" + Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)" + Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)" + Write-Host " 4. Have a filename that sorts after the last migration in base" + Write-Host "" + Write-Host "To fix this issue:" + Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/" + Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql" + Write-Host " 3. Ensure the date is after $lastBaseMigration" + Write-Host "" + Write-Host "Example: 2025-01-15_00_AddNewFeature.sql" + exit 1 +} + +Write-Host "SUCCESS: All new migrations are correctly named and in chronological order" +exit 0 diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 0d992cb96a..2ea539f39f 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -473,6 +473,7 @@ public class OrganizationsController : Controller organization.UseOrganizationDomains = model.UseOrganizationDomains; organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies; organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation; + organization.UsePhishingBlocker = model.UsePhishingBlocker; //secrets organization.SmSeats = model.SmSeats; diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index 6059a003b6..4fff85e1e8 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -107,6 +107,7 @@ public class OrganizationEditModel : OrganizationViewModel MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts; UseOrganizationDomains = org.UseOrganizationDomains; UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation; + UsePhishingBlocker = org.UsePhishingBlocker; _plans = plans; } @@ -160,6 +161,8 @@ public class OrganizationEditModel : OrganizationViewModel public new bool UseSecretsManager { get; set; } [Display(Name = "Risk Insights")] public new bool UseRiskInsights { get; set; } + [Display(Name = "Phishing Blocker")] + public new bool UsePhishingBlocker { get; set; } [Display(Name = "Admin Sponsored Families")] public bool UseAdminSponsoredFamilies { get; set; } [Display(Name = "Self Host")] @@ -327,6 +330,7 @@ public class OrganizationEditModel : OrganizationViewModel existingOrganization.SmServiceAccounts = SmServiceAccounts; existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts; existingOrganization.UseOrganizationDomains = UseOrganizationDomains; + existingOrganization.UsePhishingBlocker = UsePhishingBlocker; return existingOrganization; } } diff --git a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs index 2c126ecd8e..457686be53 100644 --- a/src/Admin/AdminConsole/Models/OrganizationViewModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationViewModel.cs @@ -75,6 +75,7 @@ public class OrganizationViewModel public int OccupiedSmSeatsCount { get; set; } public bool UseSecretsManager => Organization.UseSecretsManager; public bool UseRiskInsights => Organization.UseRiskInsights; + public bool UsePhishingBlocker => Organization.UsePhishingBlocker; public IEnumerable OwnersDetails { get; set; } public IEnumerable AdminsDetails { get; set; } } diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index cb71c0fc78..b22859ed60 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -156,6 +156,10 @@ +
+ + +
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) {
diff --git a/src/Admin/HostedServices/DatabaseMigrationHostedService.cs b/src/Admin/HostedServices/DatabaseMigrationHostedService.cs index 434c265f26..219e6846bd 100644 --- a/src/Admin/HostedServices/DatabaseMigrationHostedService.cs +++ b/src/Admin/HostedServices/DatabaseMigrationHostedService.cs @@ -19,7 +19,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable public virtual async Task StartAsync(CancellationToken cancellationToken) { // Wait 20 seconds to allow database to come online - await Task.Delay(20000); + await Task.Delay(20000, cancellationToken); var maxMigrationAttempts = 10; for (var i = 1; i <= maxMigrationAttempts; i++) @@ -41,7 +41,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable { _logger.LogError(e, "Database unavailable for migration. Trying again (attempt #{0})...", i + 1); - await Task.Delay(20000); + await Task.Delay(20000, cancellationToken); } } } diff --git a/src/Admin/Program.cs b/src/Admin/Program.cs index 05bf35d41d..006a8223b2 100644 --- a/src/Admin/Program.cs +++ b/src/Admin/Program.cs @@ -16,19 +16,8 @@ public class Program o.Limits.MaxRequestLineSize = 20_000; }); webBuilder.UseStartup(); - webBuilder.ConfigureLogging((hostingContext, logging) => - logging.AddSerilog(hostingContext, (e, globalSettings) => - { - var context = e.Properties["SourceContext"].ToString(); - if (e.Properties.TryGetValue("RequestPath", out var requestPath) && - !string.IsNullOrWhiteSpace(requestPath?.ToString()) && - (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) - { - return false; - } - return e.Level >= globalSettings.MinLogLevel.AdminSettings.Default; - })); }) + .AddSerilogFileLogging() .Build() .Run(); } diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index 5ecbdc899c..87d68a7ac6 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -132,11 +132,8 @@ public class Startup public void Configure( IApplicationBuilder app, IWebHostEnvironment env, - IHostApplicationLifetime appLifetime, GlobalSettings globalSettings) { - app.UseSerilog(env, appLifetime, globalSettings); - // Add general security headers app.UseMiddleware(); diff --git a/src/Admin/appsettings.Development.json b/src/Admin/appsettings.Development.json index 861f9be98d..15d61f493f 100644 --- a/src/Admin/appsettings.Development.json +++ b/src/Admin/appsettings.Development.json @@ -27,6 +27,7 @@ }, "storage": { "connectionString": "UseDevelopmentStorage=true" - } + }, + "pricingUri": "https://billingpricing.qa.bitwarden.pw" } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs index 0b7fe8dffe..f172a23529 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs @@ -1,8 +1,8 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,8 +12,10 @@ namespace Bit.Api.AdminConsole.Controllers; [Authorize("Application")] public class OrganizationIntegrationConfigurationController( ICurrentContext currentContext, - IOrganizationIntegrationRepository integrationRepository, - IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller + ICreateOrganizationIntegrationConfigurationCommand createCommand, + IUpdateOrganizationIntegrationConfigurationCommand updateCommand, + IDeleteOrganizationIntegrationConfigurationCommand deleteCommand, + IGetOrganizationIntegrationConfigurationsQuery getQuery) : Controller { [HttpGet("")] public async Task> GetAsync( @@ -24,13 +26,8 @@ public class OrganizationIntegrationConfigurationController( { throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - var configurations = await integrationConfigurationRepository.GetManyByIntegrationAsync(integrationId); + var configurations = await getQuery.GetManyByIntegrationAsync(organizationId, integrationId); return configurations .Select(configuration => new OrganizationIntegrationConfigurationResponseModel(configuration)) .ToList(); @@ -46,19 +43,11 @@ public class OrganizationIntegrationConfigurationController( { throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - if (!model.IsValidForType(integration.Type)) - { - throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}"); - } - var organizationIntegrationConfiguration = model.ToOrganizationIntegrationConfiguration(integrationId); - var configuration = await integrationConfigurationRepository.CreateAsync(organizationIntegrationConfiguration); - return new OrganizationIntegrationConfigurationResponseModel(configuration); + var configuration = model.ToOrganizationIntegrationConfiguration(integrationId); + var created = await createCommand.CreateAsync(organizationId, integrationId, configuration); + + return new OrganizationIntegrationConfigurationResponseModel(created); } [HttpPut("{configurationId:guid}")] @@ -72,26 +61,11 @@ public class OrganizationIntegrationConfigurationController( { throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - if (!model.IsValidForType(integration.Type)) - { - throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}"); - } - var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId); - if (configuration is null || configuration.OrganizationIntegrationId != integrationId) - { - throw new NotFoundException(); - } + var configuration = model.ToOrganizationIntegrationConfiguration(integrationId); + var updated = await updateCommand.UpdateAsync(organizationId, integrationId, configurationId, configuration); - var newConfiguration = model.ToOrganizationIntegrationConfiguration(configuration); - await integrationConfigurationRepository.ReplaceAsync(newConfiguration); - - return new OrganizationIntegrationConfigurationResponseModel(newConfiguration); + return new OrganizationIntegrationConfigurationResponseModel(updated); } [HttpDelete("{configurationId:guid}")] @@ -101,19 +75,8 @@ public class OrganizationIntegrationConfigurationController( { throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration == null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId); - if (configuration is null || configuration.OrganizationIntegrationId != integrationId) - { - throw new NotFoundException(); - } - - await integrationConfigurationRepository.DeleteAsync(configuration); + await deleteCommand.DeleteAsync(organizationId, integrationId, configurationId); } [HttpPost("{configurationId:guid}/delete")] diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs index 181811e892..b82fe3dfa8 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs @@ -1,8 +1,8 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,7 +12,10 @@ namespace Bit.Api.AdminConsole.Controllers; [Authorize("Application")] public class OrganizationIntegrationController( ICurrentContext currentContext, - IOrganizationIntegrationRepository integrationRepository) : Controller + ICreateOrganizationIntegrationCommand createCommand, + IUpdateOrganizationIntegrationCommand updateCommand, + IDeleteOrganizationIntegrationCommand deleteCommand, + IGetOrganizationIntegrationsQuery getQuery) : Controller { [HttpGet("")] public async Task> GetAsync(Guid organizationId) @@ -22,7 +25,7 @@ public class OrganizationIntegrationController( throw new NotFoundException(); } - var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId); + var integrations = await getQuery.GetManyByOrganizationAsync(organizationId); return integrations .Select(integration => new OrganizationIntegrationResponseModel(integration)) .ToList(); @@ -36,8 +39,10 @@ public class OrganizationIntegrationController( throw new NotFoundException(); } - var integration = await integrationRepository.CreateAsync(model.ToOrganizationIntegration(organizationId)); - return new OrganizationIntegrationResponseModel(integration); + var integration = model.ToOrganizationIntegration(organizationId); + var created = await createCommand.CreateAsync(integration); + + return new OrganizationIntegrationResponseModel(created); } [HttpPut("{integrationId:guid}")] @@ -48,14 +53,10 @@ public class OrganizationIntegrationController( throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration is null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } + var integration = model.ToOrganizationIntegration(organizationId); + var updated = await updateCommand.UpdateAsync(organizationId, integrationId, integration); - await integrationRepository.ReplaceAsync(model.ToOrganizationIntegration(integration)); - return new OrganizationIntegrationResponseModel(integration); + return new OrganizationIntegrationResponseModel(updated); } [HttpDelete("{integrationId:guid}")] @@ -66,13 +67,7 @@ public class OrganizationIntegrationController( throw new NotFoundException(); } - var integration = await integrationRepository.GetByIdAsync(integrationId); - if (integration is null || integration.OrganizationId != organizationId) - { - throw new NotFoundException(); - } - - await integrationRepository.DeleteAsync(integration); + await deleteCommand.DeleteAsync(organizationId, integrationId); } [HttpPost("{integrationId:guid}/delete")] diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 155b60ce5b..a380d2f0d9 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -41,6 +41,8 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using V1_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1.IRevokeOrganizationUserCommand; +using V2_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; namespace Bit.Api.AdminConsole.Controllers; @@ -71,11 +73,13 @@ public class OrganizationUsersController : BaseAdminConsoleController private readonly IFeatureService _featureService; private readonly IPricingClient _pricingClient; private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; + private readonly IBulkResendOrganizationInvitesCommand _bulkResendOrganizationInvitesCommand; private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand; + private readonly V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand _revokeOrganizationUserCommandVNext; private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; - private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand; + private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand; private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand; public OrganizationUsersController(IOrganizationRepository organizationRepository, @@ -103,10 +107,12 @@ public class OrganizationUsersController : BaseAdminConsoleController IConfirmOrganizationUserCommand confirmOrganizationUserCommand, IRestoreOrganizationUserCommand restoreOrganizationUserCommand, IInitPendingOrganizationCommand initPendingOrganizationCommand, - IRevokeOrganizationUserCommand revokeOrganizationUserCommand, + V1_RevokeOrganizationUserCommand revokeOrganizationUserCommand, IResendOrganizationInviteCommand resendOrganizationInviteCommand, + IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand, IAdminRecoverAccountCommand adminRecoverAccountCommand, - IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand) + IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand, + V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -131,7 +137,9 @@ public class OrganizationUsersController : BaseAdminConsoleController _featureService = featureService; _pricingClient = pricingClient; _resendOrganizationInviteCommand = resendOrganizationInviteCommand; + _bulkResendOrganizationInvitesCommand = bulkResendOrganizationInvitesCommand; _automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand; + _revokeOrganizationUserCommandVNext = revokeOrganizationUserCommandVNext; _confirmOrganizationUserCommand = confirmOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; _initPendingOrganizationCommand = initPendingOrganizationCommand; @@ -273,7 +281,17 @@ public class OrganizationUsersController : BaseAdminConsoleController public async Task> BulkReinvite(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { var userId = _userService.GetProperUserId(User); - var result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids); + + IEnumerable> result; + if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud)) + { + result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids); + } + else + { + result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids); + } + return new ListResponseModel( result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2))); } @@ -483,43 +501,10 @@ public class OrganizationUsersController : BaseAdminConsoleController } } +#nullable enable [HttpPut("{id}/reset-password")] [Authorize] public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model) - { - if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand)) - { - // TODO: remove legacy implementation after feature flag is enabled. - return await PutResetPasswordNew(orgId, id, model); - } - - // Get the users role, since provider users aren't a member of the organization we use the owner check - var orgUserType = await _currentContext.OrganizationOwner(orgId) - ? OrganizationUserType.Owner - : _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type; - if (orgUserType == null) - { - return TypedResults.NotFound(); - } - - var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key); - if (result.Succeeded) - { - return TypedResults.Ok(); - } - - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - await Task.Delay(2000); - return TypedResults.BadRequest(ModelState); - } - -#nullable enable - // TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed. - private async Task PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model) { var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id); if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId) @@ -662,7 +647,29 @@ public class OrganizationUsersController : BaseAdminConsoleController [Authorize] public async Task> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync); + if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2)) + { + return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync); + } + + var currentUserId = _userService.GetProperUserId(User); + if (currentUserId == null) + { + throw new UnauthorizedAccessException(); + } + + var results = await _revokeOrganizationUserCommandVNext.RevokeUsersAsync( + new V2_RevokeOrganizationUserCommand.RevokeOrganizationUsersRequest( + orgId, + model.Ids.ToArray(), + new StandardUser(currentUserId.Value, await _currentContext.OrganizationOwner(orgId)))); + + return new ListResponseModel(results + .Select(result => new OrganizationUserBulkResponseModel(result.Id, + result.Result.Match( + error => error.Message, + _ => string.Empty + )))); } [HttpPatch("revoke")] diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 590895665d..100cd7caf6 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -12,7 +12,6 @@ using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Core; -using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; @@ -70,6 +69,7 @@ public class OrganizationsController : Controller private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IPricingClient _pricingClient; private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand; + private readonly IOrganizationUpdateCommand _organizationUpdateCommand; public OrganizationsController( IOrganizationRepository organizationRepository, @@ -94,7 +94,8 @@ public class OrganizationsController : Controller IOrganizationDeleteCommand organizationDeleteCommand, IPolicyRequirementQuery policyRequirementQuery, IPricingClient pricingClient, - IOrganizationUpdateKeysCommand organizationUpdateKeysCommand) + IOrganizationUpdateKeysCommand organizationUpdateKeysCommand, + IOrganizationUpdateCommand organizationUpdateCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -119,6 +120,7 @@ public class OrganizationsController : Controller _policyRequirementQuery = policyRequirementQuery; _pricingClient = pricingClient; _organizationUpdateKeysCommand = organizationUpdateKeysCommand; + _organizationUpdateCommand = organizationUpdateCommand; } [HttpGet("{id}")] @@ -224,36 +226,31 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(result.Organization, plan); } - [HttpPut("{id}")] - public async Task Put(string id, [FromBody] OrganizationUpdateRequestModel model) + [HttpPut("{organizationId:guid}")] + public async Task Put(Guid organizationId, [FromBody] OrganizationUpdateRequestModel model) { - var orgIdGuid = new Guid(id); + // If billing email is being changed, require subscription editing permissions. + // Otherwise, organization owner permissions are sufficient. + var requiresBillingPermission = model.BillingEmail is not null; + var authorized = requiresBillingPermission + ? await _currentContext.EditSubscription(organizationId) + : await _currentContext.OrganizationOwner(organizationId); - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) + if (!authorized) { - throw new NotFoundException(); + return TypedResults.Unauthorized(); } - var updateBilling = ShouldUpdateBilling(model, organization); + var commandRequest = model.ToCommandRequest(organizationId); + var updatedOrganization = await _organizationUpdateCommand.UpdateAsync(commandRequest); - var hasRequiredPermissions = updateBilling - ? await _currentContext.EditSubscription(orgIdGuid) - : await _currentContext.OrganizationOwner(orgIdGuid); - - if (!hasRequiredPermissions) - { - throw new NotFoundException(); - } - - await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling); - var plan = await _pricingClient.GetPlan(organization.PlanType); - return new OrganizationResponseModel(organization, plan); + var plan = await _pricingClient.GetPlan(updatedOrganization.PlanType); + return TypedResults.Ok(new OrganizationResponseModel(updatedOrganization, plan)); } [HttpPost("{id}")] [Obsolete("This endpoint is deprecated. Use PUT method instead")] - public async Task PostPut(string id, [FromBody] OrganizationUpdateRequestModel model) + public async Task PostPut(Guid id, [FromBody] OrganizationUpdateRequestModel model) { return await Put(id, model); } @@ -588,11 +585,4 @@ public class OrganizationsController : Controller return organization.PlanType; } - - private bool ShouldUpdateBilling(OrganizationUpdateRequestModel model, Organization organization) - { - var organizationNameChanged = model.Name != organization.Name; - var billingEmailChanged = model.BillingEmail != organization.BillingEmail; - return !_globalSettings.SelfHosted && (organizationNameChanged || billingEmailChanged); - } } diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index a5272413e2..ae1d12e887 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -42,7 +42,6 @@ public class PoliciesController : Controller private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IPolicyRepository _policyRepository; private readonly IUserService _userService; - private readonly IFeatureService _featureService; private readonly ISavePolicyCommand _savePolicyCommand; private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; @@ -55,7 +54,6 @@ public class PoliciesController : Controller IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, IOrganizationRepository organizationRepository, - IFeatureService featureService, ISavePolicyCommand savePolicyCommand, IVNextSavePolicyCommand vNextSavePolicyCommand) { @@ -69,7 +67,6 @@ public class PoliciesController : Controller _organizationRepository = organizationRepository; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; - _featureService = featureService; _savePolicyCommand = savePolicyCommand; _vNextSavePolicyCommand = vNextSavePolicyCommand; } @@ -221,9 +218,7 @@ public class PoliciesController : Controller { var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext); - var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ? - await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) : - await _savePolicyCommand.VNextSaveAsync(savePolicyRequest); + var policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest); return new PolicyResponseModel(policy); } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs index 8581c4ae1f..9341392d68 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs @@ -1,6 +1,4 @@ -using System.Text.Json; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Enums; @@ -16,38 +14,6 @@ public class OrganizationIntegrationConfigurationRequestModel public string? Template { get; set; } - public bool IsValidForType(IntegrationType integrationType) - { - switch (integrationType) - { - case IntegrationType.CloudBillingSync or IntegrationType.Scim: - return false; - case IntegrationType.Slack: - return !string.IsNullOrWhiteSpace(Template) && - IsConfigurationValid() && - IsFiltersValid(); - case IntegrationType.Webhook: - return !string.IsNullOrWhiteSpace(Template) && - IsConfigurationValid() && - IsFiltersValid(); - case IntegrationType.Hec: - return !string.IsNullOrWhiteSpace(Template) && - Configuration is null && - IsFiltersValid(); - case IntegrationType.Datadog: - return !string.IsNullOrWhiteSpace(Template) && - Configuration is null && - IsFiltersValid(); - case IntegrationType.Teams: - return !string.IsNullOrWhiteSpace(Template) && - Configuration is null && - IsFiltersValid(); - default: - return false; - - } - } - public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(Guid organizationIntegrationId) { return new OrganizationIntegrationConfiguration() @@ -59,50 +25,4 @@ public class OrganizationIntegrationConfigurationRequestModel Template = Template }; } - - public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(OrganizationIntegrationConfiguration currentConfiguration) - { - currentConfiguration.Configuration = Configuration; - currentConfiguration.EventType = EventType; - currentConfiguration.Filters = Filters; - currentConfiguration.Template = Template; - - return currentConfiguration; - } - - private bool IsConfigurationValid() - { - if (string.IsNullOrWhiteSpace(Configuration)) - { - return false; - } - - try - { - var config = JsonSerializer.Deserialize(Configuration); - return config is not null; - } - catch - { - return false; - } - } - - private bool IsFiltersValid() - { - if (Filters is null) - { - return true; - } - - try - { - var filters = JsonSerializer.Deserialize(Filters); - return filters is not null; - } - catch - { - return false; - } - } } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs index 5a3192c121..6c3867fe09 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUpdateRequestModel.cs @@ -1,41 +1,28 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Models.Data; -using Bit.Core.Settings; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Request.Organizations; public class OrganizationUpdateRequestModel { - [Required] [StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")] [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string Name { get; set; } - [StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")] - [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string BusinessName { get; set; } - [EmailAddress] - [Required] - [StringLength(256)] - public string BillingEmail { get; set; } - public Permissions Permissions { get; set; } - public OrganizationKeysRequestModel Keys { get; set; } + public string? Name { get; set; } - public virtual Organization ToOrganization(Organization existingOrganization, GlobalSettings globalSettings) + [EmailAddress] + [StringLength(256)] + public string? BillingEmail { get; set; } + + public OrganizationKeysRequestModel? Keys { get; set; } + + public OrganizationUpdateRequest ToCommandRequest(Guid organizationId) => new() { - if (!globalSettings.SelfHosted) - { - // These items come from the license file - existingOrganization.Name = Name; - existingOrganization.BusinessName = BusinessName; - existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim(); - } - Keys?.ToOrganization(existingOrganization); - return existingOrganization; - } + OrganizationId = organizationId, + Name = Name, + BillingEmail = BillingEmail, + PublicKey = Keys?.PublicKey, + EncryptedPrivateKey = Keys?.EncryptedPrivateKey + }; } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs index 4e0accb9e8..b7a4db3acd 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationUserRequestModels.cs @@ -119,7 +119,7 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel public class OrganizationUserBulkRequestModel { - [Required] + [Required, MinLength(1)] public IEnumerable Ids { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs index c172c45e94..f5ef468b4e 100644 --- a/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs @@ -47,6 +47,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies; UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation; UseSecretsManager = organizationDetails.UseSecretsManager; + UsePhishingBlocker = organizationDetails.UsePhishingBlocker; UsePasswordManager = organizationDetails.UsePasswordManager; SelfHost = organizationDetails.SelfHost; Seats = organizationDetails.Seats; @@ -99,6 +100,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UsePhishingBlocker { get; set; } public bool SelfHost { get; set; } public int? Seats { get; set; } public short? MaxCollections { get; set; } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs index 8006a85734..9a3543f4bb 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -1,10 +1,13 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using System.Security.Claims; using System.Text.Json.Serialization; using Bit.Api.Models.Response; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Models.Api; using Bit.Core.Models.Business; @@ -71,6 +74,7 @@ public class OrganizationResponseModel : ResponseModel UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation; + UsePhishingBlocker = organization.UsePhishingBlocker; } public Guid Id { get; set; } @@ -120,6 +124,7 @@ public class OrganizationResponseModel : ResponseModel public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UsePhishingBlocker { get; set; } } public class OrganizationSubscriptionResponseModel : OrganizationResponseModel @@ -175,6 +180,30 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel } } + public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license, ClaimsPrincipal claimsPrincipal) : + this(organization, (Plan)null) + { + if (license != null) + { + // CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim + // The token's expiration is cryptographically secured and cannot be tampered with + // The file's Expires property can be manually edited and should NOT be trusted for display + if (claimsPrincipal != null) + { + Expiration = claimsPrincipal.GetValue(OrganizationLicenseConstants.Expires); + ExpirationWithoutGracePeriod = claimsPrincipal.GetValue(OrganizationLicenseConstants.ExpirationWithoutGracePeriod); + } + else + { + // No token - use the license file expiration (for older licenses without tokens) + Expiration = license.Expires; + ExpirationWithoutGracePeriod = license.ExpirationWithoutGracePeriod ?? (license.Trial + ? license.Expires + : license.Expires?.AddDays(-Constants.OrganizationSelfHostSubscriptionGracePeriodDays)); + } + } + } + public string StorageName { get; set; } public double? StorageGb { get; set; } public BillingCustomerDiscount CustomerDiscount { get; set; } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs index 81ca801308..0507de7a55 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs @@ -30,6 +30,7 @@ public class PolicyResponseModel : ResponseModel { Data = JsonSerializer.Deserialize>(policy.Data); } + RevisionDate = policy.RevisionDate; } public Guid Id { get; set; } @@ -37,4 +38,5 @@ public class PolicyResponseModel : ResponseModel public PolicyType Type { get; set; } public Dictionary Data { get; set; } public bool Enabled { get; set; } + public DateTime RevisionDate { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index 97a58d038a..8c52092dae 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -1,4 +1,5 @@ -using Bit.Core.Enums; +using Bit.Core.Billing.Models; +using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Utilities; @@ -27,7 +28,7 @@ public class ProfileOrganizationResponseModel : BaseProfileOrganizationResponseM FamilySponsorshipToDelete = organizationDetails.FamilySponsorshipToDelete; FamilySponsorshipValidUntil = organizationDetails.FamilySponsorshipValidUntil; FamilySponsorshipAvailable = (organizationDetails.FamilySponsorshipFriendlyName == null || IsAdminInitiated) && - StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise) + SponsoredPlans.Get(PlanSponsorshipType.FamiliesForEnterprise) .UsersCanSponsor(organizationDetails); AccessSecretsManager = organizationDetails.AccessSecretsManager; } diff --git a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs index be0997f271..cf8da813be 100644 --- a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs @@ -5,15 +5,10 @@ using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; -using Bit.Core; -using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Services; using Bit.Core.Context; -using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,25 +19,16 @@ namespace Bit.Api.AdminConsole.Public.Controllers; public class PoliciesController : Controller { private readonly IPolicyRepository _policyRepository; - private readonly IPolicyService _policyService; private readonly ICurrentContext _currentContext; - private readonly IFeatureService _featureService; - private readonly ISavePolicyCommand _savePolicyCommand; private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; public PoliciesController( IPolicyRepository policyRepository, - IPolicyService policyService, ICurrentContext currentContext, - IFeatureService featureService, - ISavePolicyCommand savePolicyCommand, IVNextSavePolicyCommand vNextSavePolicyCommand) { _policyRepository = policyRepository; - _policyService = policyService; _currentContext = currentContext; - _featureService = featureService; - _savePolicyCommand = savePolicyCommand; _vNextSavePolicyCommand = vNextSavePolicyCommand; } @@ -97,17 +83,8 @@ public class PoliciesController : Controller [ProducesResponseType((int)HttpStatusCode.NotFound)] public async Task Put(PolicyType type, [FromBody] PolicyUpdateRequestModel model) { - Policy policy; - if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) - { - var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type); - policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel); - } - else - { - var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type); - policy = await _savePolicyCommand.SaveAsync(policyUpdate); - } + var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type); + var policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel); var response = new PolicyResponseModel(policy); return new JsonResult(response); diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index 138549e92d..48fedfc8c1 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -33,7 +33,7 @@ - + diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 0af46fb57c..ba6cf66859 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -9,7 +9,6 @@ using Bit.Api.Models.Response; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; -using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; using Bit.Core.Context; @@ -35,7 +34,7 @@ public class TwoFactorController : Controller private readonly IOrganizationService _organizationService; private readonly UserManager _userManager; private readonly ICurrentContext _currentContext; - private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand; + private readonly IAuthRequestRepository _authRequestRepository; private readonly IDuoUniversalTokenService _duoUniversalTokenService; private readonly IDataProtectorTokenFactory _twoFactorAuthenticatorDataProtector; private readonly IDataProtectorTokenFactory _ssoEmailTwoFactorSessionDataProtector; @@ -47,7 +46,7 @@ public class TwoFactorController : Controller IOrganizationService organizationService, UserManager userManager, ICurrentContext currentContext, - IVerifyAuthRequestCommand verifyAuthRequestCommand, + IAuthRequestRepository authRequestRepository, IDuoUniversalTokenService duoUniversalConfigService, IDataProtectorTokenFactory twoFactorAuthenticatorDataProtector, IDataProtectorTokenFactory ssoEmailTwoFactorSessionDataProtector, @@ -58,7 +57,7 @@ public class TwoFactorController : Controller _organizationService = organizationService; _userManager = userManager; _currentContext = currentContext; - _verifyAuthRequestCommand = verifyAuthRequestCommand; + _authRequestRepository = authRequestRepository; _duoUniversalTokenService = duoUniversalConfigService; _twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector; _ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector; @@ -350,14 +349,15 @@ public class TwoFactorController : Controller if (user != null) { - // Check if 2FA email is from Passwordless. + // Check if 2FA email is from a device approval ("Log in with device") scenario. if (!string.IsNullOrEmpty(requestModel.AuthRequestAccessCode)) { - if (await _verifyAuthRequestCommand - .VerifyAuthRequestAsync(new Guid(requestModel.AuthRequestId), - requestModel.AuthRequestAccessCode)) + var authRequest = await _authRequestRepository.GetByIdAsync(new Guid(requestModel.AuthRequestId)); + if (authRequest != null && + authRequest.IsValidForAuthentication(user.Id, requestModel.AuthRequestAccessCode)) { await _twoFactorEmailService.SendTwoFactorEmailAsync(user); + return; } } else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken)) diff --git a/src/Api/Billing/Controllers/AccountsBillingController.cs b/src/Api/Billing/Controllers/AccountsBillingController.cs index 7abcf8c357..99b6a47da0 100644 --- a/src/Api/Billing/Controllers/AccountsBillingController.cs +++ b/src/Api/Billing/Controllers/AccountsBillingController.cs @@ -1,7 +1,5 @@ -#nullable enable -using Bit.Api.Billing.Models.Responses; +using Bit.Api.Billing.Models.Responses; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Requests; using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -16,6 +14,7 @@ public class AccountsBillingController( IUserService userService, IPaymentHistoryService paymentHistoryService) : Controller { + // TODO: Migrate to Query / AccountBillingVNextController [HttpGet("history")] [SelfHosted(NotSelfHostedOnly = true)] public async Task GetBillingHistoryAsync() @@ -30,20 +29,7 @@ public class AccountsBillingController( return new BillingHistoryResponseModel(billingInfo); } - [HttpGet("payment-method")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetPaymentMethodAsync() - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var billingInfo = await paymentService.GetBillingAsync(user); - return new BillingPaymentResponseModel(billingInfo); - } - + // TODO: Migrate to Query / AccountBillingVNextController [HttpGet("invoices")] public async Task GetInvoicesAsync([FromQuery] string? status = null, [FromQuery] string? startAfter = null) { @@ -62,6 +48,7 @@ public class AccountsBillingController( return TypedResults.Ok(invoices); } + // TODO: Migrate to Query / AccountBillingVNextController [HttpGet("transactions")] public async Task GetTransactionsAsync([FromQuery] DateTime? startAfter = null) { @@ -78,18 +65,4 @@ public class AccountsBillingController( return TypedResults.Ok(transactions); } - - [HttpPost("preview-invoice")] - public async Task PreviewInvoiceAsync([FromBody] PreviewIndividualInvoiceRequestBody model) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var invoice = await paymentService.PreviewInvoiceAsync(model, user.GatewayCustomerId, user.GatewaySubscriptionId); - - return TypedResults.Ok(invoice); - } } diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 075218dd74..e136513c77 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Api.Models.Request; +using Bit.Api.Models.Request; using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Response; using Bit.Api.Utilities; @@ -26,8 +24,10 @@ public class AccountsController( IUserService userService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IUserAccountKeysQuery userAccountKeysQuery, - IFeatureService featureService) : Controller + IFeatureService featureService, + ILicensingService licensingService) : Controller { + // TODO: Remove when pm-24996-implement-upgrade-from-free-dialog is removed [HttpPost("premium")] public async Task PostPremiumAsync( PremiumRequestModel model, @@ -75,6 +75,7 @@ public class AccountsController( }; } + // TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work. [HttpGet("subscription")] public async Task GetSubscriptionAsync( [FromServices] GlobalSettings globalSettings, @@ -97,12 +98,14 @@ public class AccountsController( var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); - return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); + return new SubscriptionResponseModel(user, subscriptionInfo, license, claimsPrincipal, includeMilestone2Discount); } else { var license = await userService.GenerateLicenseAsync(user); - return new SubscriptionResponseModel(user, license); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); + return new SubscriptionResponseModel(user, null, license, claimsPrincipal); } } else @@ -111,29 +114,7 @@ public class AccountsController( } } - [HttpPost("payment")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostPaymentAsync([FromBody] PaymentRequestModel model) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - await userService.ReplacePaymentMethodAsync(user, model.PaymentToken, model.PaymentMethodType!.Value, - new TaxInfo - { - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressCity = model.City, - BillingAddressState = model.State, - BillingAddressCountry = model.Country, - BillingAddressPostalCode = model.PostalCode, - TaxIdNumber = model.TaxId - }); - } - + // TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription [HttpPost("storage")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostStorageAsync([FromBody] StorageRequestModel model) @@ -148,8 +129,11 @@ public class AccountsController( return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; } - - + /* + * TODO: A new version of this exists in the AccountBillingVNextController. + * The individual-self-hosting-license-uploader.component needs to be updated to use it. + * Then, this can be removed. + */ [HttpPost("license")] [SelfHosted(SelfHostedOnly = true)] public async Task PostLicenseAsync(LicenseRequestModel model) @@ -169,6 +153,7 @@ public class AccountsController( await userService.UpdateLicenseAsync(user, license); } + // TODO: Migrate to Command / AccountBillingVNextController as DELETE /account/billing/vnext/subscription [HttpPost("cancel")] public async Task PostCancelAsync( [FromBody] SubscriptionCancellationRequestModel request, @@ -186,6 +171,7 @@ public class AccountsController( user.IsExpired()); } + // TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate [HttpPost("reinstate-premium")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostReinstateAsync() @@ -199,41 +185,6 @@ public class AccountsController( await userService.ReinstatePremiumAsync(user); } - [HttpGet("tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetTaxInfoAsync( - [FromServices] IPaymentService paymentService) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var taxInfo = await paymentService.GetTaxInfoAsync(user); - return new TaxInfoResponseModel(taxInfo); - } - - [HttpPut("tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PutTaxInfoAsync( - [FromBody] TaxInfoUpdateRequestModel model, - [FromServices] IPaymentService paymentService) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - var taxInfo = new TaxInfo - { - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - }; - await paymentService.SaveTaxInfoAsync(user, taxInfo); - } - private async Task> GetOrganizationIdsClaimingUserAsync(Guid userId) { var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId); diff --git a/src/Api/Billing/Controllers/InvoicesController.cs b/src/Api/Billing/Controllers/InvoicesController.cs deleted file mode 100644 index 30ea975e09..0000000000 --- a/src/Api/Billing/Controllers/InvoicesController.cs +++ /dev/null @@ -1,45 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Tax.Requests; -using Bit.Core.Context; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Billing.Controllers; - -[Route("invoices")] -[Authorize("Application")] -public class InvoicesController : BaseBillingController -{ - [HttpPost("preview-organization")] - public async Task PreviewInvoiceAsync( - [FromBody] PreviewOrganizationInvoiceRequestBody model, - [FromServices] ICurrentContext currentContext, - [FromServices] IOrganizationRepository organizationRepository, - [FromServices] IPaymentService paymentService) - { - Organization organization = null; - if (model.OrganizationId != default) - { - if (!await currentContext.EditPaymentMethods(model.OrganizationId)) - { - return Error.Unauthorized(); - } - - organization = await organizationRepository.GetByIdAsync(model.OrganizationId); - if (organization == null) - { - return Error.NotFound(); - } - } - - var invoice = await paymentService.PreviewInvoiceAsync(model, organization?.GatewayCustomerId, - organization?.GatewaySubscriptionId); - - return TypedResults.Ok(invoice); - } -} diff --git a/src/Api/Billing/Controllers/LicensesController.cs b/src/Api/Billing/Controllers/LicensesController.cs deleted file mode 100644 index 29313bd4d8..0000000000 --- a/src/Api/Billing/Controllers/LicensesController.cs +++ /dev/null @@ -1,91 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces; -using Bit.Core.Billing.Models.Business; -using Bit.Core.Billing.Organizations.Models; -using Bit.Core.Billing.Organizations.Queries; -using Bit.Core.Context; -using Bit.Core.Exceptions; -using Bit.Core.Models.Api.OrganizationLicenses; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Billing.Controllers; - -[Route("licenses")] -[Authorize("Licensing")] -[SelfHosted(NotSelfHostedOnly = true)] -public class LicensesController : Controller -{ - private readonly IUserRepository _userRepository; - private readonly IUserService _userService; - private readonly IOrganizationRepository _organizationRepository; - private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery; - private readonly IValidateBillingSyncKeyCommand _validateBillingSyncKeyCommand; - private readonly ICurrentContext _currentContext; - - public LicensesController( - IUserRepository userRepository, - IUserService userService, - IOrganizationRepository organizationRepository, - IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery, - IValidateBillingSyncKeyCommand validateBillingSyncKeyCommand, - ICurrentContext currentContext) - { - _userRepository = userRepository; - _userService = userService; - _organizationRepository = organizationRepository; - _getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery; - _validateBillingSyncKeyCommand = validateBillingSyncKeyCommand; - _currentContext = currentContext; - } - - [HttpGet("user/{id}")] - public async Task GetUser(string id, [FromQuery] string key) - { - var user = await _userRepository.GetByIdAsync(new Guid(id)); - if (user == null) - { - return null; - } - else if (!user.LicenseKey.Equals(key)) - { - await Task.Delay(2000); - throw new BadRequestException("Invalid license key."); - } - - var license = await _userService.GenerateLicenseAsync(user, null); - return license; - } - - /// - /// Used by self-hosted installations to get an updated license file - /// - [HttpGet("organization/{id}")] - public async Task OrganizationSync(string id, [FromBody] SelfHostedOrganizationLicenseRequestModel model) - { - var organization = await _organizationRepository.GetByIdAsync(new Guid(id)); - if (organization == null) - { - throw new NotFoundException("Organization not found."); - } - - if (!organization.LicenseKey.Equals(model.LicenseKey)) - { - await Task.Delay(2000); - throw new BadRequestException("Invalid license key."); - } - - if (!await _validateBillingSyncKeyCommand.ValidateBillingSyncKeyAsync(organization, model.BillingSyncKey)) - { - throw new BadRequestException("Invalid Billing Sync Key"); - } - - var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value); - return license; - } -} diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 6e4cacc155..a0a3e48b60 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -20,9 +20,9 @@ public class OrganizationBillingController( IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, IPaymentService paymentService, - ISubscriberService subscriberService, IPaymentHistoryService paymentHistoryService) : BaseBillingController { + // TODO: Remove when pm-25379-use-new-organization-metadata-structure is removed. [HttpGet("metadata")] public async Task GetMetadataAsync([FromRoute] Guid organizationId) { @@ -41,6 +41,7 @@ public class OrganizationBillingController( return TypedResults.Ok(metadata); } + // TODO: Migrate to Query / OrganizationBillingVNextController [HttpGet("history")] public async Task GetHistoryAsync([FromRoute] Guid organizationId) { @@ -61,6 +62,7 @@ public class OrganizationBillingController( return TypedResults.Ok(billingInfo); } + // TODO: Migrate to Query / OrganizationBillingVNextController [HttpGet("invoices")] public async Task GetInvoicesAsync([FromRoute] Guid organizationId, [FromQuery] string? status = null, [FromQuery] string? startAfter = null) { @@ -85,6 +87,7 @@ public class OrganizationBillingController( return TypedResults.Ok(invoices); } + // TODO: Migrate to Query / OrganizationBillingVNextController [HttpGet("transactions")] public async Task GetTransactionsAsync([FromRoute] Guid organizationId, [FromQuery] DateTime? startAfter = null) { @@ -108,6 +111,7 @@ public class OrganizationBillingController( return TypedResults.Ok(transactions); } + // TODO: Can be removed once we do away with the organization-plans.component. [HttpGet] [SelfHosted(NotSelfHostedOnly = true)] public async Task GetBillingAsync(Guid organizationId) @@ -131,127 +135,7 @@ public class OrganizationBillingController( return TypedResults.Ok(response); } - [HttpGet("payment-method")] - public async Task GetPaymentMethodAsync([FromRoute] Guid organizationId) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - var paymentMethod = await subscriberService.GetPaymentMethod(organization); - - var response = PaymentMethodResponse.From(paymentMethod); - - return TypedResults.Ok(response); - } - - [HttpPut("payment-method")] - public async Task UpdatePaymentMethodAsync( - [FromRoute] Guid organizationId, - [FromBody] UpdatePaymentMethodRequestBody requestBody) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain(); - - var taxInformation = requestBody.TaxInformation.ToDomain(); - - await organizationBillingService.UpdatePaymentMethod(organization, tokenizedPaymentSource, taxInformation); - - return TypedResults.Ok(); - } - - [HttpPost("payment-method/verify-bank-account")] - public async Task VerifyBankAccountAsync( - [FromRoute] Guid organizationId, - [FromBody] VerifyBankAccountRequestBody requestBody) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM")) - { - return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'"); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - await subscriberService.VerifyBankAccount(organization, requestBody.DescriptorCode); - - return TypedResults.Ok(); - } - - [HttpGet("tax-information")] - public async Task GetTaxInformationAsync([FromRoute] Guid organizationId) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - var taxInformation = await subscriberService.GetTaxInformation(organization); - - var response = TaxInformationResponse.From(taxInformation); - - return TypedResults.Ok(response); - } - - [HttpPut("tax-information")] - public async Task UpdateTaxInformationAsync( - [FromRoute] Guid organizationId, - [FromBody] TaxInformationRequestBody requestBody) - { - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - - if (organization == null) - { - return Error.NotFound(); - } - - var taxInformation = requestBody.ToDomain(); - - await subscriberService.UpdateTaxInformation(organization, taxInformation); - - return TypedResults.Ok(); - } - + // TODO: Migrate to Command / OrganizationBillingVNextController [HttpPost("setup-business-unit")] [SelfHosted(NotSelfHostedOnly = true)] public async Task SetupBusinessUnitAsync( @@ -280,6 +164,7 @@ public class OrganizationBillingController( return TypedResults.Ok(providerId); } + // TODO: Migrate to Command / OrganizationBillingVNextController [HttpPost("change-frequency")] [SelfHosted(NotSelfHostedOnly = true)] public async Task ChangePlanSubscriptionFrequencyAsync( diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 5494c5a90e..16fb00a3e7 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -19,7 +19,6 @@ using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; @@ -67,7 +66,8 @@ public class OrganizationsController( if (globalSettings.SelfHosted) { var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization); - return new OrganizationSubscriptionResponseModel(organization, orgLicense); + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(orgLicense); + return new OrganizationSubscriptionResponseModel(organization, orgLicense, claimsPrincipal); } var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); @@ -248,53 +248,6 @@ public class OrganizationsController( await organizationService.ReinstateSubscriptionAsync(id); } - [HttpGet("{id:guid}/tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task GetTaxInfo(Guid id) - { - if (!await currentContext.OrganizationOwner(id)) - { - throw new NotFoundException(); - } - - var organization = await organizationRepository.GetByIdAsync(id); - if (organization == null) - { - throw new NotFoundException(); - } - - var taxInfo = await paymentService.GetTaxInfoAsync(organization); - return new TaxInfoResponseModel(taxInfo); - } - - [HttpPut("{id:guid}/tax")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PutTaxInfo(Guid id, [FromBody] ExpandedTaxInfoUpdateRequestModel model) - { - if (!await currentContext.OrganizationOwner(id)) - { - throw new NotFoundException(); - } - - var organization = await organizationRepository.GetByIdAsync(id); - if (organization == null) - { - throw new NotFoundException(); - } - - var taxInfo = new TaxInfo - { - TaxIdNumber = model.TaxId, - BillingAddressLine1 = model.Line1, - BillingAddressLine2 = model.Line2, - BillingAddressCity = model.City, - BillingAddressState = model.State, - BillingAddressPostalCode = model.PostalCode, - BillingAddressCountry = model.Country, - }; - await paymentService.SaveTaxInfoAsync(organization, taxInfo); - } - /// /// Tries to grant owner access to the Secrets Manager for the organization /// diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index 006a7ce068..d358f8efd2 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -1,7 +1,6 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Pricing; @@ -9,7 +8,6 @@ using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; using Bit.Core.Models.BitStripe; using Bit.Core.Services; @@ -34,6 +32,7 @@ public class ProviderBillingController( IStripeAdapter stripeAdapter, IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService) { + // TODO: Migrate to Query / ProviderBillingVNextController [HttpGet("invoices")] public async Task GetInvoicesAsync([FromRoute] Guid providerId) { @@ -54,6 +53,7 @@ public class ProviderBillingController( return TypedResults.Ok(response); } + // TODO: Migrate to Query / ProviderBillingVNextController [HttpGet("invoices/{invoiceId}")] public async Task GenerateClientInvoiceReportAsync([FromRoute] Guid providerId, string invoiceId) { @@ -76,51 +76,7 @@ public class ProviderBillingController( "text/csv"); } - [HttpPut("payment-method")] - public async Task UpdatePaymentMethodAsync( - [FromRoute] Guid providerId, - [FromBody] UpdatePaymentMethodRequestBody requestBody) - { - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); - - if (provider == null) - { - return result; - } - - var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain(); - var taxInformation = requestBody.TaxInformation.ToDomain(); - - await providerBillingService.UpdatePaymentMethod( - provider, - tokenizedPaymentSource, - taxInformation); - - return TypedResults.Ok(); - } - - [HttpPost("payment-method/verify-bank-account")] - public async Task VerifyBankAccountAsync( - [FromRoute] Guid providerId, - [FromBody] VerifyBankAccountRequestBody requestBody) - { - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); - - if (provider == null) - { - return result; - } - - if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM")) - { - return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'"); - } - - await subscriberService.VerifyBankAccount(provider, requestBody.DescriptorCode); - - return TypedResults.Ok(); - } - + // TODO: Migrate to Query / ProviderBillingVNextController [HttpGet("subscription")] public async Task GetSubscriptionAsync([FromRoute] Guid providerId) { @@ -172,53 +128,4 @@ public class ProviderBillingController( return TypedResults.Ok(response); } - - [HttpGet("tax-information")] - public async Task GetTaxInformationAsync([FromRoute] Guid providerId) - { - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); - - if (provider == null) - { - return result; - } - - var taxInformation = await subscriberService.GetTaxInformation(provider); - - var response = TaxInformationResponse.From(taxInformation); - - return TypedResults.Ok(response); - } - - [HttpPut("tax-information")] - public async Task UpdateTaxInformationAsync( - [FromRoute] Guid providerId, - [FromBody] TaxInformationRequestBody requestBody) - { - var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId); - - if (provider == null) - { - return result; - } - - if (requestBody is not { Country: not null, PostalCode: not null }) - { - return Error.BadRequest("Country and postal code are required to update your tax information."); - } - - var taxInformation = new TaxInformation( - requestBody.Country, - requestBody.PostalCode, - requestBody.TaxId, - requestBody.TaxIdType, - requestBody.Line1, - requestBody.Line2, - requestBody.City, - requestBody.State); - - await subscriberService.UpdateTaxInformation(provider, taxInformation); - - return TypedResults.Ok(); - } } diff --git a/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingVNextController.cs similarity index 92% rename from src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs rename to src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingVNextController.cs index 973a7d99a1..b86f29bdbc 100644 --- a/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs +++ b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingVNextController.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Premium; using Bit.Api.Utilities; using Bit.Core; @@ -17,7 +16,7 @@ namespace Bit.Api.Billing.Controllers.VNext; [Authorize("Application")] [Route("account/billing/vnext/self-host")] [SelfHosted(SelfHostedOnly = true)] -public class SelfHostedAccountBillingController( +public class SelfHostedAccountBillingVNextController( ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController { [HttpPost("license")] diff --git a/src/Api/Billing/Controllers/VNext/SelfHostedBillingController.cs b/src/Api/Billing/Controllers/VNext/SelfHostedOrganizationBillingVNextController.cs similarity index 95% rename from src/Api/Billing/Controllers/VNext/SelfHostedBillingController.cs rename to src/Api/Billing/Controllers/VNext/SelfHostedOrganizationBillingVNextController.cs index bd40c777dc..625a97c998 100644 --- a/src/Api/Billing/Controllers/VNext/SelfHostedBillingController.cs +++ b/src/Api/Billing/Controllers/VNext/SelfHostedOrganizationBillingVNextController.cs @@ -14,7 +14,7 @@ namespace Bit.Api.Billing.Controllers.VNext; [Authorize("Application")] [Route("organizations/{organizationId:guid}/billing/vnext/self-host")] [SelfHosted(SelfHostedOnly = true)] -public class SelfHostedBillingController( +public class SelfHostedOrganizationBillingVNextController( IGetOrganizationMetadataQuery getOrganizationMetadataQuery) : BaseBillingController { [Authorize] diff --git a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs deleted file mode 100644 index a1b754a9dc..0000000000 --- a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs +++ /dev/null @@ -1,31 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; -using Bit.Core.Billing.Tax.Models; - -namespace Bit.Api.Billing.Models.Requests; - -public class TaxInformationRequestBody -{ - [Required] - public string Country { get; set; } - [Required] - public string PostalCode { get; set; } - public string TaxId { get; set; } - public string TaxIdType { get; set; } - public string Line1 { get; set; } - public string Line2 { get; set; } - public string City { get; set; } - public string State { get; set; } - - public TaxInformation ToDomain() => new( - Country, - PostalCode, - TaxId, - TaxIdType, - Line1, - Line2, - City, - State); -} diff --git a/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs b/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs deleted file mode 100644 index b469ce2576..0000000000 --- a/src/Api/Billing/Models/Requests/TokenizedPaymentSourceRequestBody.cs +++ /dev/null @@ -1,25 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; -using Bit.Api.Utilities; -using Bit.Core.Billing.Models; -using Bit.Core.Enums; - -namespace Bit.Api.Billing.Models.Requests; - -public class TokenizedPaymentSourceRequestBody -{ - [Required] - [EnumMatches( - PaymentMethodType.BankAccount, - PaymentMethodType.Card, - PaymentMethodType.PayPal, - ErrorMessage = "'type' must be BankAccount, Card or PayPal")] - public PaymentMethodType Type { get; set; } - - [Required] - public string Token { get; set; } - - public TokenizedPaymentSource ToDomain() => new(Type, Token); -} diff --git a/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs b/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs deleted file mode 100644 index 05ab1e34c9..0000000000 --- a/src/Api/Billing/Models/Requests/UpdatePaymentMethodRequestBody.cs +++ /dev/null @@ -1,15 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; - -namespace Bit.Api.Billing.Models.Requests; - -public class UpdatePaymentMethodRequestBody -{ - [Required] - public TokenizedPaymentSourceRequestBody PaymentSource { get; set; } - - [Required] - public TaxInformationRequestBody TaxInformation { get; set; } -} diff --git a/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs b/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs deleted file mode 100644 index e248d55dde..0000000000 --- a/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs +++ /dev/null @@ -1,12 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; - -namespace Bit.Api.Billing.Models.Requests; - -public class VerifyBankAccountRequestBody -{ - [Required] - public string DescriptorCode { get; set; } -} diff --git a/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs b/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs deleted file mode 100644 index f305e41c4f..0000000000 --- a/src/Api/Billing/Models/Responses/BillingPaymentResponseModel.cs +++ /dev/null @@ -1,20 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Billing.Models; -using Bit.Core.Models.Api; - -namespace Bit.Api.Billing.Models.Responses; - -public class BillingPaymentResponseModel : ResponseModel -{ - public BillingPaymentResponseModel(BillingInfo billing) - : base("billingPayment") - { - Balance = billing.Balance; - PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null; - } - - public decimal Balance { get; set; } - public BillingSource PaymentSource { get; set; } -} diff --git a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs deleted file mode 100644 index a54ac0a876..0000000000 --- a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Bit.Core.Billing.Models; -using Bit.Core.Billing.Tax.Models; - -namespace Bit.Api.Billing.Models.Responses; - -public record PaymentMethodResponse( - decimal AccountCredit, - PaymentSource PaymentSource, - string SubscriptionStatus, - TaxInformation TaxInformation) -{ - public static PaymentMethodResponse From(PaymentMethod paymentMethod) => - new( - paymentMethod.AccountCredit, - paymentMethod.PaymentSource, - paymentMethod.SubscriptionStatus, - paymentMethod.TaxInformation); -} diff --git a/src/Api/Billing/Models/Responses/PaymentSourceResponse.cs b/src/Api/Billing/Models/Responses/PaymentSourceResponse.cs deleted file mode 100644 index 2c9a63b1d0..0000000000 --- a/src/Api/Billing/Models/Responses/PaymentSourceResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Bit.Core.Billing.Models; -using Bit.Core.Enums; - -namespace Bit.Api.Billing.Models.Responses; - -public record PaymentSourceResponse( - PaymentMethodType Type, - string Description, - bool NeedsVerification) -{ - public static PaymentSourceResponse From(PaymentSource paymentMethod) - => new( - paymentMethod.Type, - paymentMethod.Description, - paymentMethod.NeedsVerification); -} diff --git a/src/Api/Billing/Models/Responses/TaxInformationResponse.cs b/src/Api/Billing/Models/Responses/TaxInformationResponse.cs deleted file mode 100644 index 59e4934751..0000000000 --- a/src/Api/Billing/Models/Responses/TaxInformationResponse.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Bit.Core.Billing.Tax.Models; - -namespace Bit.Api.Billing.Models.Responses; - -public record TaxInformationResponse( - string Country, - string PostalCode, - string TaxId, - string Line1, - string Line2, - string City, - string State) -{ - public static TaxInformationResponse From(TaxInformation taxInformation) - => new( - taxInformation.Country, - taxInformation.PostalCode, - taxInformation.TaxId, - taxInformation.Line1, - taxInformation.Line2, - taxInformation.City, - taxInformation.State); -} diff --git a/src/Api/Dirt/Controllers/HibpController.cs b/src/Api/Dirt/Controllers/HibpController.cs index d108fdbd4f..8060384502 100644 --- a/src/Api/Dirt/Controllers/HibpController.cs +++ b/src/Api/Dirt/Controllers/HibpController.cs @@ -66,7 +66,10 @@ public class HibpController : Controller } else if (response.StatusCode == HttpStatusCode.NotFound) { - return new NotFoundResult(); + /* 12/1/2025 - Per the HIBP API, If the domain does not have any email addresses in any breaches, + an HTTP 404 response will be returned. API also specifies that "404 Not found is the account could + not be found and has therefore not been pwned". Per REST semantics we will return 200 OK with empty array. */ + return Content("[]", "application/json"); } else if (response.StatusCode == HttpStatusCode.TooManyRequests && retry) { diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 7968970048..b944cdd052 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -1,8 +1,8 @@ -#nullable enable -using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.KeyManagement.Models.Requests; +using Bit.Api.KeyManagement.Models.Responses; using Bit.Api.KeyManagement.Validators; using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models.Request; @@ -14,6 +14,7 @@ using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Queries.Interfaces; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Repositories; using Bit.Core.Services; @@ -45,11 +46,13 @@ public class AccountsKeyManagementController : Controller private readonly IRotationValidator, IEnumerable> _webauthnKeyValidator; private readonly IRotationValidator, IEnumerable> _deviceValidator; + private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery; public AccountsKeyManagementController(IUserService userService, IFeatureService featureService, IOrganizationUserRepository organizationUserRepository, IEmergencyAccessRepository emergencyAccessRepository, + IKeyConnectorConfirmationDetailsQuery keyConnectorConfirmationDetailsQuery, IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand, IRotateUserAccountKeysCommand rotateUserKeyCommandV2, IRotationValidator, IEnumerable> cipherValidator, @@ -75,12 +78,13 @@ public class AccountsKeyManagementController : Controller _organizationUserValidator = organizationUserValidator; _webauthnKeyValidator = webAuthnKeyValidator; _deviceValidator = deviceValidator; + _keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery; } [HttpPost("key-management/regenerate-keys")] public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request) { - if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration)) + if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration) && !_featureService.IsEnabled(FeatureFlagKeys.DataRecoveryTool)) { throw new NotFoundException(); } @@ -178,4 +182,17 @@ public class AccountsKeyManagementController : Controller throw new BadRequestException(ModelState); } + + [HttpGet("key-connector/confirmation-details/{orgSsoIdentifier}")] + public async Task GetKeyConnectorConfirmationDetailsAsync(string orgSsoIdentifier) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var details = await _keyConnectorConfirmationDetailsQuery.Run(orgSsoIdentifier, user.Id); + return new KeyConnectorConfirmationDetailsResponseModel(details); + } } diff --git a/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs index 02780b015a..3510be9546 100644 --- a/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/RotateAccountKeysAndDataRequestModel.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Api.Request; namespace Bit.Api.KeyManagement.Models.Requests; diff --git a/src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs b/src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs new file mode 100644 index 0000000000..68d2c689df --- /dev/null +++ b/src/Api/KeyManagement/Models/Responses/KeyConnectorConfirmationDetailsResponseModel.cs @@ -0,0 +1,24 @@ +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Models.Api; + +namespace Bit.Api.KeyManagement.Models.Responses; + +public class KeyConnectorConfirmationDetailsResponseModel : ResponseModel +{ + private const string _objectName = "keyConnectorConfirmationDetails"; + + public KeyConnectorConfirmationDetailsResponseModel(KeyConnectorConfirmationDetails details, + string obj = _objectName) : base(obj) + { + ArgumentNullException.ThrowIfNull(details); + + OrganizationName = details.OrganizationName; + } + + public KeyConnectorConfirmationDetailsResponseModel() : base(_objectName) + { + OrganizationName = string.Empty; + } + + public string OrganizationName { get; set; } +} diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 29a47e160c..32d12aa416 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -1,4 +1,7 @@ -using Bit.Core.Billing.Constants; +using System.Security.Claims; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Models.Business; using Bit.Core.Entities; using Bit.Core.Models.Api; @@ -37,6 +40,46 @@ public class SubscriptionResponseModel : ResponseModel : null; } + /// The user entity containing storage and premium subscription information + /// Subscription information retrieved from the payment provider (Stripe/Braintree) + /// The user's license containing expiration and feature entitlements + /// The claims principal containing cryptographically secure token claims + /// + /// Whether to include discount information in the response. + /// Set to true when the PM23341_Milestone_2 feature flag is enabled AND + /// you want to expose Milestone 2 discount information to the client. + /// The discount will only be included if it matches the specific Milestone 2 coupon ID. + /// + public SubscriptionResponseModel(User user, SubscriptionInfo? subscription, UserLicense license, ClaimsPrincipal? claimsPrincipal, bool includeMilestone2Discount = false) + : base("subscription") + { + Subscription = subscription?.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; + UpcomingInvoice = subscription?.UpcomingInvoice != null ? + new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null; + StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; + StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB + MaxStorageGb = user.MaxStorageGb; + License = license; + + // CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim + // The token's expiration is cryptographically secured and cannot be tampered with + // The file's Expires property can be manually edited and should NOT be trusted for display + if (claimsPrincipal != null) + { + Expiration = claimsPrincipal.GetValue(UserLicenseConstants.Expires); + } + else + { + // No token - use the license file expiration (for older licenses without tokens) + Expiration = License.Expires; + } + + // Only display the Milestone 2 subscription discount on the subscription page. + CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription?.CustomerDiscount) + ? new BillingCustomerDiscount(subscription!.CustomerDiscount!) + : null; + } + public SubscriptionResponseModel(User user, UserLicense? license = null) : base("subscription") { diff --git a/src/Api/Program.cs b/src/Api/Program.cs index 6023f51c6d..bf924af47f 100644 --- a/src/Api/Program.cs +++ b/src/Api/Program.cs @@ -1,9 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using AspNetCoreRateLimit; -using Bit.Core.Utilities; -using Microsoft.IdentityModel.Tokens; +using Bit.Core.Utilities; namespace Bit.Api; @@ -17,32 +12,8 @@ public class Program .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); - webBuilder.ConfigureLogging((hostingContext, logging) => - logging.AddSerilog(hostingContext, (e, globalSettings) => - { - var context = e.Properties["SourceContext"].ToString(); - if (e.Exception != null && - (e.Exception.GetType() == typeof(SecurityTokenValidationException) || - e.Exception.Message == "Bad security stamp.")) - { - return false; - } - - if ( - context.Contains(typeof(IpRateLimitMiddleware).FullName)) - { - return e.Level >= globalSettings.MinLogLevel.ApiSettings.IpRateLimit; - } - - if (context.Contains("Duende.IdentityServer.Validation.TokenValidator") || - context.Contains("Duende.IdentityServer.Validation.TokenRequestValidator")) - { - return e.Level >= globalSettings.MinLogLevel.ApiSettings.IdentityToken; - } - - return e.Level >= globalSettings.MinLogLevel.ApiSettings.Default; - })); }) + .AddSerilogFileLogging() .Build() .Run(); } diff --git a/src/Api/SecretsManager/Controllers/SecretVersionsController.cs b/src/Api/SecretsManager/Controllers/SecretVersionsController.cs new file mode 100644 index 0000000000..86e2d1f7e9 --- /dev/null +++ b/src/Api/SecretsManager/Controllers/SecretVersionsController.cs @@ -0,0 +1,337 @@ +using Bit.Api.Models.Response; +using Bit.Api.SecretsManager.Models.Request; +using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Auth.Identity; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.SecretsManager.Controllers; + +[Authorize("secrets")] +public class SecretVersionsController : Controller +{ + private readonly ICurrentContext _currentContext; + private readonly ISecretVersionRepository _secretVersionRepository; + private readonly ISecretRepository _secretRepository; + private readonly IUserService _userService; + private readonly IOrganizationUserRepository _organizationUserRepository; + + public SecretVersionsController( + ICurrentContext currentContext, + ISecretVersionRepository secretVersionRepository, + ISecretRepository secretRepository, + IUserService userService, + IOrganizationUserRepository organizationUserRepository) + { + _currentContext = currentContext; + _secretVersionRepository = secretVersionRepository; + _secretRepository = secretRepository; + _userService = userService; + _organizationUserRepository = organizationUserRepository; + } + + [HttpGet("secrets/{secretId}/versions")] + public async Task> GetVersionsBySecretIdAsync([FromRoute] Guid secretId) + { + var secret = await _secretRepository.GetByIdAsync(secretId); + if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId)) + { + throw new NotFoundException(); + } + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount || + _currentContext.IdentityClientType == IdentityClientType.Organization) + { + // Already verified Secrets Manager access above + var versionList = await _secretVersionRepository.GetManyBySecretIdAsync(secretId); + var responseList = versionList.Select(v => new SecretVersionResponseModel(v)); + return new ListResponseModel(responseList); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin); + + var access = await _secretRepository.AccessToSecretAsync(secretId, userId.Value, accessClient); + if (!access.Read) + { + throw new NotFoundException(); + } + + var versions = await _secretVersionRepository.GetManyBySecretIdAsync(secretId); + var responses = versions.Select(v => new SecretVersionResponseModel(v)); + + return new ListResponseModel(responses); + } + + [HttpGet("secret-versions/{id}")] + public async Task GetByIdAsync([FromRoute] Guid id) + { + var secretVersion = await _secretVersionRepository.GetByIdAsync(id); + if (secretVersion == null) + { + throw new NotFoundException(); + } + + var secret = await _secretRepository.GetByIdAsync(secretVersion.SecretId); + if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId)) + { + throw new NotFoundException(); + } + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount || + _currentContext.IdentityClientType == IdentityClientType.Organization) + { + // Already verified Secrets Manager access above + return new SecretVersionResponseModel(secretVersion); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin); + + var access = await _secretRepository.AccessToSecretAsync(secretVersion.SecretId, userId.Value, accessClient); + if (!access.Read) + { + throw new NotFoundException(); + } + + return new SecretVersionResponseModel(secretVersion); + } + + [HttpPost("secret-versions/get-by-ids")] + public async Task> GetManyByIdsAsync([FromBody] List ids) + { + if (!ids.Any()) + { + throw new BadRequestException("No version IDs provided."); + } + + // Get all versions + var versions = (await _secretVersionRepository.GetManyByIdsAsync(ids)).ToList(); + if (!versions.Any()) + { + throw new NotFoundException(); + } + + // Get all associated secrets and check permissions + var secretIds = versions.Select(v => v.SecretId).Distinct().ToList(); + var secrets = (await _secretRepository.GetManyByIds(secretIds)).ToList(); + + if (!secrets.Any()) + { + throw new NotFoundException(); + } + + // Ensure all secrets belong to the same organization + var organizationId = secrets.First().OrganizationId; + if (secrets.Any(s => s.OrganizationId != organizationId) || + !_currentContext.AccessSecretsManager(organizationId)) + { + throw new NotFoundException(); + } + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount || + _currentContext.IdentityClientType == IdentityClientType.Organization) + { + // Already verified Secrets Manager access and organization ownership above + var serviceAccountResponses = versions.Select(v => new SecretVersionResponseModel(v)); + return new ListResponseModel(serviceAccountResponses); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var isAdmin = await _currentContext.OrganizationAdmin(organizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, isAdmin); + + // Verify read access to all associated secrets + var accessResults = await _secretRepository.AccessToSecretsAsync(secretIds, userId.Value, accessClient); + if (accessResults.Values.Any(access => !access.Read)) + { + throw new NotFoundException(); + } + + var responses = versions.Select(v => new SecretVersionResponseModel(v)); + return new ListResponseModel(responses); + } + + [HttpPut("secrets/{secretId}/versions/restore")] + public async Task RestoreVersionAsync([FromRoute] Guid secretId, [FromBody] RestoreSecretVersionRequestModel request) + { + if (!(_currentContext.IdentityClientType == IdentityClientType.User || _currentContext.IdentityClientType == IdentityClientType.ServiceAccount)) + { + throw new NotFoundException(); + } + + var secret = await _secretRepository.GetByIdAsync(secretId); + if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId)) + { + throw new NotFoundException(); + } + + // Get the version first to validate it belongs to this secret + var version = await _secretVersionRepository.GetByIdAsync(request.VersionId); + if (version == null || version.SecretId != secretId) + { + throw new NotFoundException(); + } + + // Store the current value before restoration + var currentValue = secret.Value; + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount) + { + // Save current value as a version before restoring + if (currentValue != version.Value) + { + var editorUserId = _userService.GetProperUserId(User); + if (editorUserId.HasValue) + { + var currentVersionSnapshot = new Core.SecretsManager.Entities.SecretVersion + { + SecretId = secretId, + Value = currentValue!, + VersionDate = DateTime.UtcNow, + EditorServiceAccountId = editorUserId.Value + }; + + await _secretVersionRepository.CreateAsync(currentVersionSnapshot); + } + } + + // Already verified Secrets Manager access above + secret.Value = version.Value; + secret.RevisionDate = DateTime.UtcNow; + var updatedSec = await _secretRepository.UpdateAsync(secret); + return new SecretResponseModel(updatedSec, true, true); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin); + + var access = await _secretRepository.AccessToSecretAsync(secretId, userId.Value, accessClient); + if (!access.Write) + { + throw new NotFoundException(); + } + + // Save current value as a version before restoring + if (currentValue != version.Value) + { + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(secret.OrganizationId, userId.Value); + if (orgUser == null) + { + throw new NotFoundException(); + } + + var currentVersionSnapshot = new Core.SecretsManager.Entities.SecretVersion + { + SecretId = secretId, + Value = currentValue!, + VersionDate = DateTime.UtcNow, + EditorOrganizationUserId = orgUser.Id + }; + + await _secretVersionRepository.CreateAsync(currentVersionSnapshot); + } + + // Update the secret with the version's value + secret.Value = version.Value; + secret.RevisionDate = DateTime.UtcNow; + + var updatedSecret = await _secretRepository.UpdateAsync(secret); + + return new SecretResponseModel(updatedSecret, true, true); + } + + [HttpPost("secret-versions/delete")] + public async Task BulkDeleteAsync([FromBody] List ids) + { + if (!ids.Any()) + { + throw new BadRequestException("No version IDs provided."); + } + + var secretVersions = (await _secretVersionRepository.GetManyByIdsAsync(ids)).ToList(); + if (secretVersions.Count != ids.Count) + { + throw new NotFoundException(); + } + + // Ensure all versions belong to secrets in the same organization + var secretIds = secretVersions.Select(v => v.SecretId).Distinct().ToList(); + var secrets = await _secretRepository.GetManyByIds(secretIds); + var secretsList = secrets.ToList(); + + if (!secretsList.Any()) + { + throw new NotFoundException(); + } + + var organizationId = secretsList.First().OrganizationId; + if (secretsList.Any(s => s.OrganizationId != organizationId) || + !_currentContext.AccessSecretsManager(organizationId)) + { + throw new NotFoundException(); + } + + // For service accounts and organization API, skip user-level access checks + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount || + _currentContext.IdentityClientType == IdentityClientType.Organization) + { + // Already verified Secrets Manager access and organization ownership above + await _secretVersionRepository.DeleteManyByIdAsync(ids); + return Ok(); + } + + var userId = _userService.GetProperUserId(User); + if (!userId.HasValue) + { + throw new NotFoundException(); + } + + var orgAdmin = await _currentContext.OrganizationAdmin(organizationId); + var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin); + + // Verify write access to all associated secrets + var accessResults = await _secretRepository.AccessToSecretsAsync(secretIds, userId.Value, accessClient); + if (accessResults.Values.Any(access => !access.Write)) + { + throw new NotFoundException(); + } + + await _secretVersionRepository.DeleteManyByIdAsync(ids); + + return Ok(); + } +} diff --git a/src/Api/SecretsManager/Controllers/SecretsController.cs b/src/Api/SecretsManager/Controllers/SecretsController.cs index e263b9747d..dcfe1be111 100644 --- a/src/Api/SecretsManager/Controllers/SecretsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsController.cs @@ -8,6 +8,7 @@ using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Repositories; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; using Bit.Core.SecretsManager.Entities; @@ -29,6 +30,7 @@ public class SecretsController : Controller private readonly ICurrentContext _currentContext; private readonly IProjectRepository _projectRepository; private readonly ISecretRepository _secretRepository; + private readonly ISecretVersionRepository _secretVersionRepository; private readonly ICreateSecretCommand _createSecretCommand; private readonly IUpdateSecretCommand _updateSecretCommand; private readonly IDeleteSecretCommand _deleteSecretCommand; @@ -38,11 +40,13 @@ public class SecretsController : Controller private readonly IUserService _userService; private readonly IEventService _eventService; private readonly IAuthorizationService _authorizationService; + private readonly IOrganizationUserRepository _organizationUserRepository; public SecretsController( ICurrentContext currentContext, IProjectRepository projectRepository, ISecretRepository secretRepository, + ISecretVersionRepository secretVersionRepository, ICreateSecretCommand createSecretCommand, IUpdateSecretCommand updateSecretCommand, IDeleteSecretCommand deleteSecretCommand, @@ -51,11 +55,13 @@ public class SecretsController : Controller ISecretAccessPoliciesUpdatesQuery secretAccessPoliciesUpdatesQuery, IUserService userService, IEventService eventService, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + IOrganizationUserRepository organizationUserRepository) { _currentContext = currentContext; _projectRepository = projectRepository; _secretRepository = secretRepository; + _secretVersionRepository = secretVersionRepository; _createSecretCommand = createSecretCommand; _updateSecretCommand = updateSecretCommand; _deleteSecretCommand = deleteSecretCommand; @@ -65,6 +71,7 @@ public class SecretsController : Controller _userService = userService; _eventService = eventService; _authorizationService = authorizationService; + _organizationUserRepository = organizationUserRepository; } @@ -190,6 +197,44 @@ public class SecretsController : Controller } } + // Create a version record if the value changed + if (updateRequest.ValueChanged) + { + // Store the old value before updating + var oldValue = secret.Value; + var userId = _userService.GetProperUserId(User)!.Value; + Guid? editorServiceAccountId = null; + Guid? editorOrganizationUserId = null; + + if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount) + { + editorServiceAccountId = userId; + } + else if (_currentContext.IdentityClientType == IdentityClientType.User) + { + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(secret.OrganizationId, userId); + if (orgUser != null) + { + editorOrganizationUserId = orgUser.Id; + } + else + { + throw new NotFoundException(); + } + } + + var secretVersion = new SecretVersion + { + SecretId = id, + Value = oldValue, + VersionDate = DateTime.UtcNow, + EditorServiceAccountId = editorServiceAccountId, + EditorOrganizationUserId = editorOrganizationUserId + }; + + await _secretVersionRepository.CreateAsync(secretVersion); + } + var result = await _updateSecretCommand.UpdateAsync(updatedSecret, accessPoliciesUpdates); await LogSecretEventAsync(secret, EventType.Secret_Edited); diff --git a/src/Api/SecretsManager/Models/Request/RestoreSecretVersionRequestModel.cs b/src/Api/SecretsManager/Models/Request/RestoreSecretVersionRequestModel.cs new file mode 100644 index 0000000000..19a6b35a75 --- /dev/null +++ b/src/Api/SecretsManager/Models/Request/RestoreSecretVersionRequestModel.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.SecretsManager.Models.Request; + +public class RestoreSecretVersionRequestModel +{ + [Required] + public Guid VersionId { get; set; } +} diff --git a/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs b/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs index b95bc9e500..9d19e1d8cc 100644 --- a/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs +++ b/src/Api/SecretsManager/Models/Request/SecretUpdateRequestModel.cs @@ -28,6 +28,8 @@ public class SecretUpdateRequestModel : IValidatableObject public SecretAccessPoliciesRequestsModel AccessPoliciesRequests { get; set; } + public bool ValueChanged { get; set; } = false; + public Secret ToSecret(Secret secret) { secret.Key = Key; diff --git a/src/Api/SecretsManager/Models/Response/SecretVersionResponseModel.cs b/src/Api/SecretsManager/Models/Response/SecretVersionResponseModel.cs new file mode 100644 index 0000000000..07b8e88f7e --- /dev/null +++ b/src/Api/SecretsManager/Models/Response/SecretVersionResponseModel.cs @@ -0,0 +1,28 @@ +using Bit.Core.Models.Api; +using Bit.Core.SecretsManager.Entities; + +namespace Bit.Api.SecretsManager.Models.Response; + +public class SecretVersionResponseModel : ResponseModel +{ + private const string _objectName = "secretVersion"; + + public Guid Id { get; set; } + public Guid SecretId { get; set; } + public string Value { get; set; } = string.Empty; + public DateTime VersionDate { get; set; } + public Guid? EditorServiceAccountId { get; set; } + public Guid? EditorOrganizationUserId { get; set; } + + public SecretVersionResponseModel() : base(_objectName) { } + + public SecretVersionResponseModel(SecretVersion secretVersion) : base(_objectName) + { + Id = secretVersion.Id; + SecretId = secretVersion.SecretId; + Value = secretVersion.Value; + VersionDate = secretVersion.VersionDate; + EditorServiceAccountId = secretVersion.EditorServiceAccountId; + EditorOrganizationUserId = secretVersion.EditorOrganizationUserId; + } +} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 0967b4f662..bdbc2f8edc 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -216,7 +216,7 @@ public class Startup config.Conventions.Add(new PublicApiControllersModelConvention()); }); - services.AddSwagger(globalSettings, Environment); + services.AddSwaggerGen(globalSettings, Environment); Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted); services.AddHostedService(); @@ -226,7 +226,8 @@ public class Startup services.AddHostedService(); } - // Add Slack / Teams Services for OAuth API requests - if configured + // Add Event Integrations services + services.AddEventIntegrationsCommandsQueries(globalSettings); services.AddSlackService(globalSettings); services.AddTeamsService(globalSettings); } @@ -234,12 +235,10 @@ public class Startup public void Configure( IApplicationBuilder app, IWebHostEnvironment env, - IHostApplicationLifetime appLifetime, GlobalSettings globalSettings, ILogger logger) { IdentityModelEventSource.ShowPII = true; - app.UseSerilog(env, appLifetime, globalSettings); // Add general security headers app.UseMiddleware(); @@ -294,17 +293,59 @@ public class Startup }); // Add Swagger + // Note that the swagger.json generation is configured in the call to AddSwaggerGen above. if (Environment.IsDevelopment() || globalSettings.SelfHosted) { + // adds the middleware to serve the swagger.json while the server is running app.UseSwagger(config => { config.RouteTemplate = "specs/{documentName}/swagger.json"; + + // Remove all Bitwarden cloud servers and only register the local server config.PreSerializeFilters.Add((swaggerDoc, httpReq) => - swaggerDoc.Servers = new List + { + swaggerDoc.Servers.Clear(); + swaggerDoc.Servers.Add(new OpenApiServer { - new OpenApiServer { Url = globalSettings.BaseServiceUri.Api } + Url = globalSettings.BaseServiceUri.Api, }); + + swaggerDoc.Components.SecuritySchemes.Clear(); + swaggerDoc.Components.SecuritySchemes.Add("oauth2-client-credentials", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + ClientCredentials = new OpenApiOAuthFlow + { + TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"), + Scopes = new Dictionary + { + { ApiScopes.ApiOrganization, "Organization APIs" } + } + } + } + }); + + swaggerDoc.SecurityRequirements.Clear(); + swaggerDoc.SecurityRequirements.Add(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "oauth2-client-credentials" + } + }, + [ApiScopes.ApiOrganization] + } + }); + }); }); + + // adds the middleware to display the web UI app.UseSwaggerUI(config => { config.DocumentTitle = "Bitwarden API Documentation"; diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index 6af688f548..c90fc82d56 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -1,6 +1,5 @@ using Bit.Api.AdminConsole.Authorization; using Bit.Api.Tools.Authorization; -using Bit.Core.Auth.IdentityServer; using Bit.Core.PhishingDomainFeatures; using Bit.Core.PhishingDomainFeatures.Interfaces; using Bit.Core.Repositories; @@ -10,6 +9,7 @@ using Bit.Core.Utilities; using Bit.Core.Vault.Authorization.SecurityTasks; using Bit.SharedWeb.Health; using Bit.SharedWeb.Swagger; +using Bit.SharedWeb.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.OpenApi.Models; @@ -17,7 +17,10 @@ namespace Bit.Api.Utilities; public static class ServiceCollectionExtensions { - public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment) + /// + /// Configures the generation of swagger.json OpenAPI spec. + /// + public static void AddSwaggerGen(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment) { services.AddSwaggerGen(config => { @@ -36,6 +39,8 @@ public static class ServiceCollectionExtensions organizations tools for managing members, collections, groups, event logs, and policies. If you are looking for the Vault Management API, refer instead to [this document](https://bitwarden.com/help/vault-management-api/). + + **Note:** your authorization must match the server you have selected. """, License = new OpenApiLicense { @@ -46,36 +51,20 @@ public static class ServiceCollectionExtensions config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" }); - config.AddSecurityDefinition("oauth2-client-credentials", new OpenApiSecurityScheme - { - Type = SecuritySchemeType.OAuth2, - Flows = new OpenApiOAuthFlows - { - ClientCredentials = new OpenApiOAuthFlow - { - TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"), - Scopes = new Dictionary - { - { ApiScopes.ApiOrganization, "Organization APIs" }, - }, - } - }, - }); + // Configure Bitwarden cloud US and EU servers. These will appear in the swagger.json build artifact + // used for our help center. These are overwritten with the local server when running in self-hosted + // or dev mode (see Api Startup.cs). + config.AddSwaggerServerWithSecurity( + serverId: "US_server", + serverUrl: "https://api.bitwarden.com", + identityTokenUrl: "https://identity.bitwarden.com/connect/token", + serverDescription: "US server"); - config.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "oauth2-client-credentials" - }, - }, - new[] { ApiScopes.ApiOrganization } - } - }); + config.AddSwaggerServerWithSecurity( + serverId: "EU_server", + serverUrl: "https://api.bitwarden.eu", + identityTokenUrl: "https://identity.bitwarden.eu/connect/token", + serverDescription: "EU server"); config.DescribeAllParametersInCamelCase(); // config.UseReferencedDefinitionsForEnums(); diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index c200810156..6a506cc01f 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -757,15 +757,10 @@ public class CiphersController : Controller } } - if (cipher.ArchivedDate.HasValue) - { - throw new BadRequestException("Cannot move an archived item to an organization."); - } - ValidateClientVersionForFido2CredentialSupport(cipher); var original = cipher.Clone(); - await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher), new Guid(model.Cipher.OrganizationId), + await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher, user.Id), new Guid(model.Cipher.OrganizationId), model.CollectionIds.Select(c => new Guid(c)), user.Id, model.Cipher.LastKnownRevisionDate); var sharedCipher = await GetByIdAsync(id, user.Id); @@ -1271,11 +1266,6 @@ public class CiphersController : Controller _logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", cipher.Id, userId, cipher.EncryptedFor); throw new BadRequestException("Cipher was not encrypted for the current user. Please try again."); } - - if (cipher.ArchivedDate.HasValue) - { - throw new BadRequestException("Cannot move archived items to an organization."); - } } var shareCiphers = new List<(CipherDetails, DateTime?)>(); @@ -1288,11 +1278,6 @@ public class CiphersController : Controller ValidateClientVersionForFido2CredentialSupport(existingCipher); - if (existingCipher.ArchivedDate.HasValue) - { - throw new BadRequestException("Cannot move archived items to an organization."); - } - shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate)); } diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index b0589a62f9..18a1aec559 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -84,7 +84,7 @@ public class CipherRequestModel return existingCipher; } - public Cipher ToCipher(Cipher existingCipher) + public Cipher ToCipher(Cipher existingCipher, Guid? userId = null) { // If Data field is provided, use it directly if (!string.IsNullOrWhiteSpace(Data)) @@ -124,9 +124,12 @@ public class CipherRequestModel } } + var userIdKey = userId.HasValue ? userId.ToString().ToUpperInvariant() : null; existingCipher.Reprompt = Reprompt; existingCipher.Key = Key; existingCipher.ArchivedDate = ArchivedDate; + existingCipher.Folders = UpdateUserSpecificJsonField(existingCipher.Folders, userIdKey, FolderId); + existingCipher.Favorites = UpdateUserSpecificJsonField(existingCipher.Favorites, userIdKey, Favorite); var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0; @@ -291,6 +294,37 @@ public class CipherRequestModel KeyFingerprint = SSHKey.KeyFingerprint, }; } + + /// + /// Updates a JSON string representing a dictionary by adding, updating, or removing a key-value pair + /// based on the provided userIdKey and newValue. + /// + private static string UpdateUserSpecificJsonField(string existingJson, string userIdKey, object newValue) + { + if (userIdKey == null) + { + return existingJson; + } + + var jsonDict = string.IsNullOrWhiteSpace(existingJson) + ? new Dictionary() + : JsonSerializer.Deserialize>(existingJson) ?? new Dictionary(); + + var shouldRemove = newValue == null || + (newValue is string strValue && string.IsNullOrWhiteSpace(strValue)) || + (newValue is bool boolValue && !boolValue); + + if (shouldRemove) + { + jsonDict.Remove(userIdKey); + } + else + { + jsonDict[userIdKey] = newValue is string str ? str.ToUpperInvariant() : newValue; + } + + return jsonDict.Count == 0 ? null : JsonSerializer.Serialize(jsonDict); + } } public class CipherWithIdRequestModel : CipherRequestModel diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json index 82fb951261..87e92c4516 100644 --- a/src/Api/appsettings.Development.json +++ b/src/Api/appsettings.Development.json @@ -41,6 +41,7 @@ "phishingDomain": { "updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt", "checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256" - } + }, + "pricingUri": "https://billingpricing.qa.bitwarden.pw" } } diff --git a/src/Api/appsettings.json b/src/Api/appsettings.json index 98bb4df8ac..a503070d8d 100644 --- a/src/Api/appsettings.json +++ b/src/Api/appsettings.json @@ -32,9 +32,6 @@ "send": { "connectionString": "SECRET" }, - "sentry": { - "dsn": "SECRET" - }, "notificationHub": { "connectionString": "SECRET", "hubName": "SECRET" diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index e2b7447eb7..fdac4fc3e4 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/Billing/Controllers/JobsController.cs b/src/Billing/Controllers/JobsController.cs new file mode 100644 index 0000000000..6a5e8e5531 --- /dev/null +++ b/src/Billing/Controllers/JobsController.cs @@ -0,0 +1,36 @@ +using Bit.Billing.Jobs; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Billing.Controllers; + +[Route("jobs")] +[SelfHosted(NotSelfHostedOnly = true)] +[RequireLowerEnvironment] +public class JobsController( + JobsHostedService jobsHostedService) : Controller +{ + [HttpPost("run/{jobName}")] + public async Task RunJobAsync(string jobName) + { + if (jobName == nameof(ReconcileAdditionalStorageJob)) + { + await jobsHostedService.RunJobAdHocAsync(); + return Ok(new { message = $"Job {jobName} scheduled successfully" }); + } + + return BadRequest(new { error = $"Unknown job name: {jobName}" }); + } + + [HttpPost("stop/{jobName}")] + public async Task StopJobAsync(string jobName) + { + if (jobName == nameof(ReconcileAdditionalStorageJob)) + { + await jobsHostedService.InterruptAdHocJobAsync(); + return Ok(new { message = $"Job {jobName} queued for cancellation" }); + } + + return BadRequest(new { error = $"Unknown job name: {jobName}" }); + } +} diff --git a/src/Billing/Jobs/AliveJob.cs b/src/Billing/Jobs/AliveJob.cs index 42f64099ac..1769cc94e2 100644 --- a/src/Billing/Jobs/AliveJob.cs +++ b/src/Billing/Jobs/AliveJob.cs @@ -10,4 +10,13 @@ public class AliveJob(ILogger logger) : BaseJob(logger) _logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Billing service is alive!"); return Task.FromResult(0); } + + public static ITrigger GetTrigger() + { + return TriggerBuilder.Create() + .WithIdentity("EveryTopOfTheHourTrigger") + .StartNow() + .WithCronSchedule("0 0 * * * ?") + .Build(); + } } diff --git a/src/Billing/Jobs/JobsHostedService.cs b/src/Billing/Jobs/JobsHostedService.cs index a6e702c662..25c57044da 100644 --- a/src/Billing/Jobs/JobsHostedService.cs +++ b/src/Billing/Jobs/JobsHostedService.cs @@ -1,29 +1,27 @@ -using Bit.Core.Jobs; +using Bit.Core.Exceptions; +using Bit.Core.Jobs; using Bit.Core.Settings; using Quartz; namespace Bit.Billing.Jobs; -public class JobsHostedService : BaseJobsHostedService +public class JobsHostedService( + GlobalSettings globalSettings, + IServiceProvider serviceProvider, + ILogger logger, + ILogger listenerLogger, + ISchedulerFactory schedulerFactory) + : BaseJobsHostedService(globalSettings, serviceProvider, logger, listenerLogger) { - public JobsHostedService( - GlobalSettings globalSettings, - IServiceProvider serviceProvider, - ILogger logger, - ILogger listenerLogger) - : base(globalSettings, serviceProvider, logger, listenerLogger) { } + private List AdHocJobKeys { get; } = []; + private IScheduler? _adHocScheduler; public override async Task StartAsync(CancellationToken cancellationToken) { - var everyTopOfTheHourTrigger = TriggerBuilder.Create() - .WithIdentity("EveryTopOfTheHourTrigger") - .StartNow() - .WithCronSchedule("0 0 * * * ?") - .Build(); - Jobs = new List> { - new Tuple(typeof(AliveJob), everyTopOfTheHourTrigger) + new(typeof(AliveJob), AliveJob.GetTrigger()), + new(typeof(ReconcileAdditionalStorageJob), ReconcileAdditionalStorageJob.GetTrigger()) }; await base.StartAsync(cancellationToken); @@ -33,5 +31,54 @@ public class JobsHostedService : BaseJobsHostedService { services.AddTransient(); services.AddTransient(); + services.AddTransient(); + // add this service as a singleton so we can inject it where needed + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + } + + public async Task InterruptAdHocJobAsync(CancellationToken cancellationToken = default) where T : class, IJob + { + if (_adHocScheduler == null) + { + throw new InvalidOperationException("AdHocScheduler is null, cannot interrupt ad-hoc job."); + } + + var jobKey = AdHocJobKeys.FirstOrDefault(j => j.Name == typeof(T).ToString()); + if (jobKey == null) + { + throw new NotFoundException($"Cannot find job key: {typeof(T)}, not running?"); + } + logger.LogInformation("CANCELLING ad-hoc job with key: {JobKey}", jobKey); + AdHocJobKeys.Remove(jobKey); + await _adHocScheduler.Interrupt(jobKey, cancellationToken); + } + + public async Task RunJobAdHocAsync(CancellationToken cancellationToken = default) where T : class, IJob + { + _adHocScheduler ??= await schedulerFactory.GetScheduler(cancellationToken); + + var jobKey = new JobKey(typeof(T).ToString()); + + var currentlyExecuting = await _adHocScheduler.GetCurrentlyExecutingJobs(cancellationToken); + if (currentlyExecuting.Any(j => j.JobDetail.Key.Equals(jobKey))) + { + throw new InvalidOperationException($"Job {jobKey} is already running"); + } + + AdHocJobKeys.Add(jobKey); + + var job = JobBuilder.Create() + .WithIdentity(jobKey) + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity(typeof(T).ToString()) + .StartNow() + .Build(); + + logger.LogInformation("Scheduling ad-hoc job with key: {JobKey}", jobKey); + + await _adHocScheduler.ScheduleJob(job, trigger, cancellationToken); } } diff --git a/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs new file mode 100644 index 0000000000..d891fc18ff --- /dev/null +++ b/src/Billing/Jobs/ReconcileAdditionalStorageJob.cs @@ -0,0 +1,207 @@ +using System.Globalization; +using System.Text.Json; +using Bit.Billing.Services; +using Bit.Core; +using Bit.Core.Billing.Constants; +using Bit.Core.Jobs; +using Bit.Core.Services; +using Quartz; +using Stripe; + +namespace Bit.Billing.Jobs; + +public class ReconcileAdditionalStorageJob( + IStripeFacade stripeFacade, + ILogger logger, + IFeatureService featureService) : BaseJob(logger) +{ + private const string _storageGbMonthlyPriceId = "storage-gb-monthly"; + private const string _storageGbAnnuallyPriceId = "storage-gb-annually"; + private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually"; + private const int _storageGbToRemove = 4; + + protected override async Task ExecuteJobAsync(IJobExecutionContext context) + { + if (!featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob)) + { + logger.LogInformation("Skipping ReconcileAdditionalStorageJob, feature flag off."); + return; + } + + var liveMode = featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode); + + // Execution tracking + var subscriptionsFound = 0; + var subscriptionsUpdated = 0; + var subscriptionsWithErrors = 0; + var failures = new List(); + + logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode); + + var priceIds = new[] { _storageGbMonthlyPriceId, _storageGbAnnuallyPriceId, _personalStorageGbAnnuallyPriceId }; + + foreach (var priceId in priceIds) + { + var options = new SubscriptionListOptions + { + Limit = 100, + Status = StripeConstants.SubscriptionStatus.Active, + Price = priceId + }; + + await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options)) + { + if (context.CancellationToken.IsCancellationRequested) + { + logger.LogWarning( + "Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " + + "Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}", + subscriptionsFound, + liveMode + ? subscriptionsUpdated + : $"(In live mode, would have updated) {subscriptionsUpdated}", + subscriptionsWithErrors, + failures.Count > 0 + ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" + : string.Empty + ); + return; + } + + if (subscription == null) + { + continue; + } + + logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id); + subscriptionsFound++; + + if (subscription.Metadata?.TryGetValue(StripeConstants.MetadataKeys.StorageReconciled2025, out var dateString) == true) + { + if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out var dateProcessed)) + { + logger.LogInformation("Skipping subscription {SubscriptionId} - already processed on {Date}", + subscription.Id, + dateProcessed.ToString("f")); + continue; + } + } + + var updateOptions = BuildSubscriptionUpdateOptions(subscription, priceId); + + if (updateOptions == null) + { + logger.LogInformation("Skipping subscription {SubscriptionId} - no updates needed", subscription.Id); + continue; + } + + subscriptionsUpdated++; + + if (!liveMode) + { + logger.LogInformation( + "Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}", + subscription.Id, + Environment.NewLine, + JsonSerializer.Serialize(updateOptions)); + continue; + } + + try + { + await stripeFacade.UpdateSubscription(subscription.Id, updateOptions); + logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id); + } + catch (Exception ex) + { + subscriptionsWithErrors++; + failures.Add($"Subscription {subscription.Id}: {ex.Message}"); + logger.LogError(ex, "Failed to update subscription {SubscriptionId}: {ErrorMessage}", + subscription.Id, ex.Message); + } + } + } + + logger.LogInformation( + "ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " + + "Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}", + subscriptionsFound, + liveMode + ? subscriptionsUpdated + : $"(In live mode, would have updated) {subscriptionsUpdated}", + subscriptionsWithErrors, + failures.Count > 0 + ? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}" + : string.Empty + ); + } + + private SubscriptionUpdateOptions? BuildSubscriptionUpdateOptions( + Subscription subscription, + string targetPriceId) + { + if (subscription.Items?.Data == null) + { + return null; + } + + var updateOptions = new SubscriptionUpdateOptions + { + ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") + }, + Items = [] + }; + + var hasUpdates = false; + + foreach (var item in subscription.Items.Data.Where(item => item?.Price?.Id == targetPriceId)) + { + hasUpdates = true; + var currentQuantity = item.Quantity; + + if (currentQuantity > _storageGbToRemove) + { + var newQuantity = currentQuantity - _storageGbToRemove; + logger.LogInformation( + "Subscription {SubscriptionId}: reducing quantity from {CurrentQuantity} to {NewQuantity} for price {PriceId}", + subscription.Id, + currentQuantity, + newQuantity, + item.Price.Id); + + updateOptions.Items.Add(new SubscriptionItemOptions + { + Id = item.Id, + Quantity = newQuantity + }); + } + else + { + logger.LogInformation("Subscription {SubscriptionId}: deleting storage item with quantity {CurrentQuantity} for price {PriceId}", + subscription.Id, + currentQuantity, + item.Price.Id); + + updateOptions.Items.Add(new SubscriptionItemOptions + { + Id = item.Id, + Deleted = true + }); + } + } + + return hasUpdates ? updateOptions : null; + } + + public static ITrigger GetTrigger() + { + return TriggerBuilder.Create() + .WithIdentity("EveryMorningTrigger") + .StartNow() + .WithCronSchedule("0 0 16 * * ?") // 10am CST daily; the pods execute in UTC time + .Build(); + } +} diff --git a/src/Billing/Jobs/SubscriptionCancellationJob.cs b/src/Billing/Jobs/SubscriptionCancellationJob.cs index 69b7bc876d..60b671df3d 100644 --- a/src/Billing/Jobs/SubscriptionCancellationJob.cs +++ b/src/Billing/Jobs/SubscriptionCancellationJob.cs @@ -1,16 +1,17 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Billing.Services; +using Bit.Billing.Services; +using Bit.Core.Billing.Constants; using Bit.Core.Repositories; using Quartz; using Stripe; namespace Bit.Billing.Jobs; +using static StripeConstants; + public class SubscriptionCancellationJob( IStripeFacade stripeFacade, - IOrganizationRepository organizationRepository) + IOrganizationRepository organizationRepository, + ILogger logger) : IJob { public async Task Execute(IJobExecutionContext context) @@ -21,20 +22,31 @@ public class SubscriptionCancellationJob( var organization = await organizationRepository.GetByIdAsync(organizationId); if (organization == null || organization.Enabled) { + logger.LogWarning("{Job} skipped for subscription ({SubscriptionID}) because organization is either null or enabled", nameof(SubscriptionCancellationJob), subscriptionId); // Organization was deleted or re-enabled by CS, skip cancellation return; } - var subscription = await stripeFacade.GetSubscription(subscriptionId); - if (subscription?.Status != "unpaid" || - subscription.LatestInvoice?.BillingReason is not ("subscription_cycle" or "subscription_create")) + var subscription = await stripeFacade.GetSubscription(subscriptionId, new SubscriptionGetOptions { + Expand = ["latest_invoice"] + }); + + if (subscription is not + { + Status: SubscriptionStatus.Unpaid, + LatestInvoice: { BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle } + }) + { + logger.LogWarning("{Job} skipped for subscription ({SubscriptionID}) because subscription is not unpaid or does not have a cancellable billing reason", nameof(SubscriptionCancellationJob), subscriptionId); return; } // Cancel the subscription await stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions()); + logger.LogInformation("{Job} cancelled subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), subscriptionId); + // Void any open invoices var options = new InvoiceListOptions { @@ -46,6 +58,7 @@ public class SubscriptionCancellationJob( foreach (var invoice in invoices) { await stripeFacade.VoidInvoice(invoice.Id); + logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId); } while (invoices.HasMore) @@ -55,6 +68,7 @@ public class SubscriptionCancellationJob( foreach (var invoice in invoices) { await stripeFacade.VoidInvoice(invoice.Id); + logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId); } } } diff --git a/src/Billing/Program.cs b/src/Billing/Program.cs index 3e005ce7fd..72ff6072c5 100644 --- a/src/Billing/Program.cs +++ b/src/Billing/Program.cs @@ -11,25 +11,8 @@ public class Program .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); - webBuilder.ConfigureLogging((hostingContext, logging) => - logging.AddSerilog(hostingContext, (e, globalSettings) => - { - var context = e.Properties["SourceContext"].ToString(); - if (context.StartsWith("\"Bit.Billing.Jobs") || context.StartsWith("\"Bit.Core.Jobs")) - { - return e.Level >= globalSettings.MinLogLevel.BillingSettings.Jobs; - } - - if (e.Properties.TryGetValue("RequestPath", out var requestPath) && - !string.IsNullOrWhiteSpace(requestPath?.ToString()) && - (context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer"))) - { - return false; - } - - return e.Level >= globalSettings.MinLogLevel.BillingSettings.Default; - })); }) + .AddSerilogFileLogging() .Build() .Run(); } diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 280a3aca3c..f821eeed5f 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -78,6 +78,11 @@ public interface IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + IAsyncEnumerable ListSubscriptionsAutoPagingAsync( + SubscriptionListOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task GetSubscription( string subscriptionId, SubscriptionGetOptions subscriptionGetOptions = null, @@ -111,4 +116,10 @@ public interface IStripeFacade TestClockGetOptions testClockGetOptions = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task GetCoupon( + string couponId, + CouponGetOptions couponGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); } diff --git a/src/Billing/Services/Implementations/StripeEventUtilityService.cs b/src/Billing/Services/Implementations/StripeEventUtilityService.cs index 49e562de56..06a5d8a890 100644 --- a/src/Billing/Services/Implementations/StripeEventUtilityService.cs +++ b/src/Billing/Services/Implementations/StripeEventUtilityService.cs @@ -3,12 +3,12 @@ using Bit.Billing.Constants; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Utilities; using Braintree; using Stripe; using Customer = Stripe.Customer; @@ -112,7 +112,7 @@ public class StripeEventUtilityService : IStripeEventUtilityService } public bool IsSponsoredSubscription(Subscription subscription) => - StaticStore.SponsoredPlans + SponsoredPlans.All .Any(p => subscription.Items .Any(i => i.Plan.Id == p.StripePlanId)); diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index eef7ce009e..bb72091bc6 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -18,6 +18,7 @@ public class StripeFacade : IStripeFacade private readonly DiscountService _discountService = new(); private readonly SetupIntentService _setupIntentService = new(); private readonly TestClockService _testClockService = new(); + private readonly CouponService _couponService = new(); public async Task GetCharge( string chargeId, @@ -98,6 +99,12 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _subscriptionService.ListAsync(options, requestOptions, cancellationToken); + public IAsyncEnumerable ListSubscriptionsAutoPagingAsync( + SubscriptionListOptions options = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + _subscriptionService.ListAutoPagingAsync(options, requestOptions, cancellationToken); + public async Task GetSubscription( string subscriptionId, SubscriptionGetOptions subscriptionGetOptions = null, @@ -137,4 +144,11 @@ public class StripeFacade : IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default) => _testClockService.GetAsync(testClockId, testClockGetOptions, requestOptions, cancellationToken); + + public Task GetCoupon( + string couponId, + CouponGetOptions couponGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + _couponService.GetAsync(couponId, couponGetOptions, requestOptions, cancellationToken); } diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 81aeb460c2..c10368d8c0 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -1,7 +1,5 @@ -using System.Globalization; -using Bit.Billing.Constants; +using Bit.Billing.Constants; using Bit.Billing.Jobs; -using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; @@ -111,8 +109,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler break; } - if (subscription.Status is StripeSubscriptionStatus.Unpaid && - subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore)) + if (await IsPremiumSubscriptionAsync(subscription)) { await CancelSubscription(subscription.Id); await VoidOpenInvoices(subscription.Id); @@ -120,6 +117,20 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd); + break; + } + case StripeSubscriptionStatus.Incomplete when userId.HasValue: + { + // Handle Incomplete subscriptions for Premium users that have open invoices from failed payments + // This prevents duplicate subscriptions when users retry the subscription flow + if (await IsPremiumSubscriptionAsync(subscription) && + subscription.LatestInvoice is { Status: StripeInvoiceStatus.Open }) + { + await CancelSubscription(subscription.Id); + await VoidOpenInvoices(subscription.Id); + await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd); + } + break; } case StripeSubscriptionStatus.Active when organizationId.HasValue: @@ -134,11 +145,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } case StripeSubscriptionStatus.Active when providerId.HasValue: { - var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); - if (!providerPortalTakeover) - { - break; - } var provider = await _providerRepository.GetByIdAsync(providerId.Value); if (provider != null) { @@ -197,6 +203,13 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } } + private async Task IsPremiumSubscriptionAsync(Subscription subscription) + { + var premiumPlans = await _pricingClient.ListPremiumPlans(); + var premiumPriceIds = premiumPlans.SelectMany(p => new[] { p.Seat.StripePriceId, p.Storage.StripePriceId }).ToHashSet(); + return subscription.Items.Any(i => premiumPriceIds.Contains(i.Price.Id)); + } + /// /// Checks if the provider subscription status has changed from a non-active to an active status type /// If the previous status is already active(active,past-due,trialing),canceled,or null, then this will return false. @@ -321,13 +334,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler Event parsedEvent, Subscription currentSubscription) { - var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); - - if (!providerPortalTakeover) - { - return; - } - var provider = await _providerRepository.GetByIdAsync(providerId); if (provider == null) { @@ -343,22 +349,17 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler { var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject() as Subscription; - var updateIsSubscriptionGoingUnpaid = previousSubscription is - { - Status: + if (previousSubscription is + { + Status: StripeSubscriptionStatus.Trialing or StripeSubscriptionStatus.Active or StripeSubscriptionStatus.PastDue - } && currentSubscription is - { - Status: StripeSubscriptionStatus.Unpaid, - LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create" - }; - - var updateIsManualSuspensionViaMetadata = CheckForManualSuspensionViaMetadata( - previousSubscription, currentSubscription); - - if (updateIsSubscriptionGoingUnpaid || updateIsManualSuspensionViaMetadata) + } && currentSubscription is + { + Status: StripeSubscriptionStatus.Unpaid, + LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create" + }) { if (currentSubscription.TestClock != null) { @@ -369,14 +370,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) }; - if (updateIsManualSuspensionViaMetadata) - { - subscriptionUpdateOptions.Metadata = new Dictionary - { - ["suspended_provider_via_webhook_at"] = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture) - }; - } - await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions); } } @@ -399,37 +392,4 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler } } } - - private static bool CheckForManualSuspensionViaMetadata( - Subscription? previousSubscription, - Subscription currentSubscription) - { - /* - * When metadata on a subscription is updated, we'll receive an event that has: - * Previous Metadata: { newlyAddedKey: null } - * Current Metadata: { newlyAddedKey: newlyAddedValue } - * - * As such, our check for a manual suspension must ensure that the 'previous_attributes' does contain the - * 'metadata' property, but also that the "suspend_provider" key in that metadata is set to null. - * - * If we don't do this and instead do a null coalescing check on 'previous_attributes?.metadata?.TryGetValue', - * we'll end up marking an event where 'previous_attributes.metadata' = null (which could be any subscription update - * that does not update the metadata) the same as a manual suspension. - */ - const string key = "suspend_provider"; - - if (previousSubscription is not { Metadata: not null } || - !previousSubscription.Metadata.TryGetValue(key, out var previousValue)) - { - return false; - } - - if (previousValue == null) - { - return !string.IsNullOrEmpty( - currentSubscription.Metadata.TryGetValue(key, out var currentValue) ? currentValue : null); - } - - return false; - } } diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 6db0cb6373..004828dc48 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,4 +1,5 @@ -using Bit.Core; +using System.Globalization; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; @@ -8,7 +9,9 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Entities; -using Bit.Core.Models.Mail.UpdatedInvoiceIncoming; +using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; +using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; +using Bit.Core.Models.Mail.Billing.Renewal.Premium; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; @@ -16,6 +19,7 @@ using Bit.Core.Services; using Stripe; using Event = Stripe.Event; using Plan = Bit.Core.Models.StaticStore.Plan; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; namespace Bit.Billing.Services.Implementations; @@ -107,13 +111,22 @@ public class UpcomingInvoiceHandler( var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3); - await AlignOrganizationSubscriptionConcernsAsync( + var subscriptionAligned = await AlignOrganizationSubscriptionConcernsAsync( organization, @event, subscription, plan, milestone3); + /* + * Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue + * with processing. + */ + if (subscriptionAligned) + { + return; + } + // Don't send the upcoming invoice email unless the organization's on an annual plan. if (!plan.IsAnnual) { @@ -135,9 +148,7 @@ public class UpcomingInvoiceHandler( } } - await (milestone3 - ? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail]) - : SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice)); + await SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice); } private async Task AlignOrganizationTaxConcernsAsync( @@ -188,7 +199,16 @@ public class UpcomingInvoiceHandler( } } - private async Task AlignOrganizationSubscriptionConcernsAsync( + /// + /// Aligns the organization's subscription details with the specified plan and milestone requirements. + /// + /// The organization whose subscription is being updated. + /// The Stripe event associated with this operation. + /// The organization's subscription. + /// The organization's current plan. + /// A flag indicating whether the third milestone is enabled. + /// Whether the operation resulted in an updated subscription. + private async Task AlignOrganizationSubscriptionConcernsAsync( Organization organization, Event @event, Subscription subscription, @@ -198,7 +218,7 @@ public class UpcomingInvoiceHandler( // currently these are the only plans that need aligned and both require the same flag and share most of the logic if (!milestone3 || plan.Type is not (PlanType.FamiliesAnnually2019 or PlanType.FamiliesAnnually2025)) { - return; + return false; } var passwordManagerItem = @@ -208,15 +228,15 @@ public class UpcomingInvoiceHandler( { logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})", organization.Id, @event.Type, @event.Id); - return; + return false; } - var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually); + var familiesPlan = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually); - organization.PlanType = families.Type; - organization.Plan = families.Name; - organization.UsersGetPremium = families.UsersGetPremium; - organization.Seats = families.PasswordManager.BaseSeats; + organization.PlanType = familiesPlan.Type; + organization.Plan = familiesPlan.Name; + organization.UsersGetPremium = familiesPlan.UsersGetPremium; + organization.Seats = familiesPlan.PasswordManager.BaseSeats; var options = new SubscriptionUpdateOptions { @@ -225,7 +245,7 @@ public class UpcomingInvoiceHandler( new SubscriptionItemOptions { Id = passwordManagerItem.Id, - Price = families.PasswordManager.StripePlanId + Price = familiesPlan.PasswordManager.StripePlanId } ], ProrationBehavior = ProrationBehavior.None @@ -266,6 +286,8 @@ public class UpcomingInvoiceHandler( { await organizationRepository.ReplaceAsync(organization); await stripeFacade.UpdateSubscription(subscription.Id, options); + await SendFamiliesRenewalEmailAsync(organization, familiesPlan, plan); + return true; } catch (Exception exception) { @@ -275,6 +297,7 @@ public class UpcomingInvoiceHandler( organization.Id, @event.Type, @event.Id); + return false; } } @@ -303,14 +326,21 @@ public class UpcomingInvoiceHandler( var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); if (milestone2Feature) { - await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription); + var subscriptionAligned = await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription); + + /* + * Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue + * with processing. + */ + if (subscriptionAligned) + { + return; + } } if (user.Premium) { - await (milestone2Feature - ? SendUpdatedUpcomingInvoiceEmailsAsync(new List { user.Email }) - : SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice)); + await SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice); } } @@ -341,7 +371,7 @@ public class UpcomingInvoiceHandler( } } - private async Task AlignPremiumUsersSubscriptionConcernsAsync( + private async Task AlignPremiumUsersSubscriptionConcernsAsync( User user, Event @event, Subscription subscription) @@ -352,7 +382,7 @@ public class UpcomingInvoiceHandler( { logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})", user.Id, @event.Type, @event.Id); - return; + return false; } try @@ -371,6 +401,8 @@ public class UpcomingInvoiceHandler( ], ProrationBehavior = ProrationBehavior.None }); + await SendPremiumRenewalEmailAsync(user, plan); + return true; } catch (Exception exception) { @@ -379,6 +411,7 @@ public class UpcomingInvoiceHandler( "Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}", user.Id, @event.Id); + return false; } } @@ -513,15 +546,92 @@ public class UpcomingInvoiceHandler( } } - private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable emails) + private async Task SendFamiliesRenewalEmailAsync( + Organization organization, + Plan familiesPlan, + Plan planBeforeAlignment) { - var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail + await (planBeforeAlignment switch { - ToEmails = validEmails, - View = new UpdatedInvoiceUpcomingView() + { Type: PlanType.FamiliesAnnually2025 } => SendFamilies2020RenewalEmailAsync(organization, familiesPlan), + { Type: PlanType.FamiliesAnnually2019 } => SendFamilies2019RenewalEmailAsync(organization, familiesPlan), + _ => throw new InvalidOperationException("Unsupported families plan in SendFamiliesRenewalEmailAsync().") + }); + } + + private async Task SendFamilies2020RenewalEmailAsync(Organization organization, Plan familiesPlan) + { + var email = new Families2020RenewalMail + { + ToEmails = [organization.BillingEmail], + View = new Families2020RenewalMailView + { + MonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) + } }; - await mailer.SendEmail(updatedUpcomingEmail); + + await mailer.SendEmail(email); + } + + private async Task SendFamilies2019RenewalEmailAsync(Organization organization, Plan familiesPlan) + { + var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount); + if (coupon == null) + { + throw new InvalidOperationException($"Coupon for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} not found"); + } + + if (coupon.PercentOff == null) + { + throw new InvalidOperationException($"coupon.PercentOff for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} is null"); + } + + var discountedAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice * (100 - coupon.PercentOff.Value) / 100; + + var email = new Families2019RenewalMail + { + ToEmails = [organization.BillingEmail], + View = new Families2019RenewalMailView + { + BaseMonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")), + BaseAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")), + DiscountAmount = $"{coupon.PercentOff}%", + DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US")) + } + }; + + await mailer.SendEmail(email); + } + + private async Task SendPremiumRenewalEmailAsync( + User user, + PremiumPlan premiumPlan) + { + var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount); + if (coupon == null) + { + throw new InvalidOperationException($"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found"); + } + + if (coupon.PercentOff == null) + { + throw new InvalidOperationException($"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null"); + } + + var discountedAnnualRenewalPrice = premiumPlan.Seat.Price * (100 - coupon.PercentOff.Value) / 100; + + var email = new PremiumRenewalMail + { + ToEmails = [user.Email], + View = new PremiumRenewalMailView + { + BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")), + DiscountAmount = $"{coupon.PercentOff}%", + DiscountedMonthlyRenewalPrice = (discountedAnnualRenewalPrice / 12).ToString("C", new CultureInfo("en-US")) + } + }; + + await mailer.SendEmail(email); } #endregion diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index cdb9700ad5..1343dc0895 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -10,7 +10,6 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Context; using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories.Noop; -using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -129,12 +128,8 @@ public class Startup public void Configure( IApplicationBuilder app, - IWebHostEnvironment env, - IHostApplicationLifetime appLifetime, - GlobalSettings globalSettings) + IWebHostEnvironment env) { - app.UseSerilog(env, appLifetime, globalSettings); - // Add general security headers app.UseMiddleware(); diff --git a/src/Billing/appsettings.Development.json b/src/Billing/appsettings.Development.json index 7c4889c22f..fe8e47b2f6 100644 --- a/src/Billing/appsettings.Development.json +++ b/src/Billing/appsettings.Development.json @@ -35,6 +35,7 @@ "billingSettings": { "onyx": { "personaId": 68 - } - } + } + }, + "pricingUri": "https://billingpricing.qa.bitwarden.pw" } diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index a2d6acd0a1..aa14f1d377 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -30,9 +30,6 @@ "connectionString": "SECRET", "applicationCacheTopicName": "SECRET" }, - "sentry": { - "dsn": "SECRET" - }, "notificationHub": { "connectionString": "SECRET", "hubName": "SECRET" diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index 73aa162f22..338b150de6 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -134,6 +134,11 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable /// public bool UseAutomaticUserConfirmation { get; set; } + /// + /// If set to true, the organization has phishing protection enabled. + /// + public bool UsePhishingBlocker { get; set; } + public void SetNewId() { if (Id == default(Guid)) @@ -334,5 +339,6 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable UseOrganizationDomains = license.UseOrganizationDomains; UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies; UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation; + UsePhishingBlocker = license.UsePhishingBlocker; } } diff --git a/src/Core/AdminConsole/Entities/OrganizationIntegration.cs b/src/Core/AdminConsole/Entities/OrganizationIntegration.cs index 86de25ce9a..f1c96c8b98 100644 --- a/src/Core/AdminConsole/Entities/OrganizationIntegration.cs +++ b/src/Core/AdminConsole/Entities/OrganizationIntegration.cs @@ -2,8 +2,6 @@ using Bit.Core.Enums; using Bit.Core.Utilities; -#nullable enable - namespace Bit.Core.AdminConsole.Entities; public class OrganizationIntegration : ITableObject diff --git a/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs b/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs index 52934cf7f3..a9ce676062 100644 --- a/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Entities/OrganizationIntegrationConfiguration.cs @@ -2,8 +2,6 @@ using Bit.Core.Enums; using Bit.Core.Utilities; -#nullable enable - namespace Bit.Core.AdminConsole.Entities; public class OrganizationIntegrationConfiguration : ITableObject diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/AdminConsole/Enums/EventType.cs index 09cda7ca0e..916f408fe6 100644 --- a/src/Core/AdminConsole/Enums/EventType.cs +++ b/src/Core/AdminConsole/Enums/EventType.cs @@ -81,6 +81,8 @@ public enum EventType : int Organization_CollectionManagement_LimitItemDeletionDisabled = 1615, Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled = 1616, Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617, + Organization_ItemOrganization_Accepted = 1618, + Organization_ItemOrganization_Declined = 1619, Policy_Updated = 1700, diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index 09fa4ec955..bd6daf7cdf 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -21,6 +21,7 @@ public enum PolicyType : byte UriMatchDefaults = 16, AutotypeDefaultSetting = 17, AutomaticUserConfirmation = 18, + BlockClaimedDomainAccountCreation = 19, } public static class PolicyTypeExtensions @@ -52,6 +53,7 @@ public static class PolicyTypeExtensions PolicyType.UriMatchDefaults => "URI match defaults", PolicyType.AutotypeDefaultSetting => "Autotype default setting", PolicyType.AutomaticUserConfirmation => "Automatically confirm invited users", + PolicyType.BlockClaimedDomainAccountCreation => "Block account creation for claimed domains", }; } } diff --git a/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs new file mode 100644 index 0000000000..6b848be11f --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/EventIntegrationsServiceCollectionExtensions.cs @@ -0,0 +1,55 @@ +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class EventIntegrationsServiceCollectionExtensions +{ + /// + /// Adds all event integrations commands, queries, and required cache infrastructure. + /// This method is idempotent and can be called multiple times safely. + /// + public static IServiceCollection AddEventIntegrationsCommandsQueries( + this IServiceCollection services, + GlobalSettings globalSettings) + { + // Ensure cache is registered first - commands depend on this keyed cache. + // This is idempotent for the same named cache, so it's safe to call. + services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings); + + // Add Validator + services.TryAddSingleton(); + + // Add all commands/queries + services.AddOrganizationIntegrationCommandsQueries(); + services.AddOrganizationIntegrationConfigurationCommandsQueries(); + + return services; + } + + internal static IServiceCollection AddOrganizationIntegrationCommandsQueries(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } + + internal static IServiceCollection AddOrganizationIntegrationConfigurationCommandsQueries(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..cb3ce8b9ea --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/CreateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,64 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Command implementation for creating organization integration configurations with validation and cache invalidation support. +/// +public class CreateOrganizationIntegrationConfigurationCommand( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache, + IOrganizationIntegrationConfigurationValidator validator) + : ICreateOrganizationIntegrationConfigurationCommand +{ + public async Task CreateAsync( + Guid organizationId, + Guid integrationId, + OrganizationIntegrationConfiguration configuration) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + if (!validator.ValidateConfiguration(integration.Type, configuration)) + { + throw new BadRequestException( + $"Invalid Configuration and/or Filters for integration type {integration.Type}"); + } + + var created = await configurationRepository.CreateAsync(configuration); + + // Invalidate the cached configuration details + // Even though this is a new record, the cache could hold a stale empty list for this + if (created.EventType == null) + { + // Wildcard configuration - invalidate all cached results for this org/integration + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + } + else + { + // Specific event type - only invalidate that specific cache entry + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: created.EventType.Value + )); + } + + return created; + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..78768fd0d4 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/DeleteOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,54 @@ +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Command implementation for deleting organization integration configurations with cache invalidation support. +/// +public class DeleteOrganizationIntegrationConfigurationCommand( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache) + : IDeleteOrganizationIntegrationConfigurationCommand +{ + public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + var configuration = await configurationRepository.GetByIdAsync(configurationId); + if (configuration is null || configuration.OrganizationIntegrationId != integrationId) + { + throw new NotFoundException(); + } + + await configurationRepository.DeleteAsync(configuration); + + if (configuration.EventType == null) + { + // Wildcard configuration - invalidate all cached results for this org/integration + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + } + else + { + // Specific event type - only invalidate that specific cache entry + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: configuration.EventType.Value + )); + } + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQuery.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQuery.cs new file mode 100644 index 0000000000..a2078c3c98 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/GetOrganizationIntegrationConfigurationsQuery.cs @@ -0,0 +1,29 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Query implementation for retrieving organization integration configurations. +/// +public class GetOrganizationIntegrationConfigurationsQuery( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository) + : IGetOrganizationIntegrationConfigurationsQuery +{ + public async Task> GetManyByIntegrationAsync( + Guid organizationId, + Guid integrationId) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + var configurations = await configurationRepository.GetManyByIntegrationAsync(integrationId); + return configurations.ToList(); + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/ICreateOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/ICreateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..140cc79d1a --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/ICreateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Command interface for creating organization integration configurations. +/// +public interface ICreateOrganizationIntegrationConfigurationCommand +{ + /// + /// Creates a new configuration for an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// The configuration to create. + /// The created configuration. + /// Thrown when the integration does not exist + /// or does not belong to the specified organization. + /// Thrown when the configuration or filters + /// are invalid for the integration type. + Task CreateAsync(Guid organizationId, Guid integrationId, OrganizationIntegrationConfiguration configuration); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IDeleteOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IDeleteOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..3970676d40 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IDeleteOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Command interface for deleting organization integration configurations. +/// +public interface IDeleteOrganizationIntegrationConfigurationCommand +{ + /// + /// Deletes a configuration from an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// The unique identifier of the configuration to delete. + /// + /// Thrown when the integration or configuration does not exist, + /// or the integration does not belong to the specified organization, + /// or the configuration does not belong to the specified integration. + Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IGetOrganizationIntegrationConfigurationsQuery.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IGetOrganizationIntegrationConfigurationsQuery.cs new file mode 100644 index 0000000000..2bf806c458 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IGetOrganizationIntegrationConfigurationsQuery.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Query interface for retrieving organization integration configurations. +/// +public interface IGetOrganizationIntegrationConfigurationsQuery +{ + /// + /// Retrieves all configurations for a specific organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// A list of configurations associated with the integration. + /// Thrown when the integration does not exist + /// or does not belong to the specified organization. + Task> GetManyByIntegrationAsync(Guid organizationId, Guid integrationId); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IUpdateOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IUpdateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..3e60a0af07 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/Interfaces/IUpdateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,25 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; + +/// +/// Command interface for updating organization integration configurations. +/// +public interface IUpdateOrganizationIntegrationConfigurationCommand +{ + /// + /// Updates an existing configuration for an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration. + /// The unique identifier of the configuration to update. + /// The updated configuration data. + /// The updated configuration. + /// + /// Thrown when the integration or the configuration does not exist, + /// or the integration does not belong to the specified organization, + /// or the configuration does not belong to the specified integration. + /// Thrown when the configuration or filters + /// are invalid for the integration type. + Task UpdateAsync(Guid organizationId, Guid integrationId, Guid configurationId, OrganizationIntegrationConfiguration updatedConfiguration); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommand.cs new file mode 100644 index 0000000000..f619e2ddf2 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrationConfigurations/UpdateOrganizationIntegrationConfigurationCommand.cs @@ -0,0 +1,82 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations; + +/// +/// Command implementation for updating organization integration configurations with validation and cache invalidation support. +/// +public class UpdateOrganizationIntegrationConfigurationCommand( + IOrganizationIntegrationRepository integrationRepository, + IOrganizationIntegrationConfigurationRepository configurationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache, + IOrganizationIntegrationConfigurationValidator validator) + : IUpdateOrganizationIntegrationConfigurationCommand +{ + public async Task UpdateAsync( + Guid organizationId, + Guid integrationId, + Guid configurationId, + OrganizationIntegrationConfiguration updatedConfiguration) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration == null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + var configuration = await configurationRepository.GetByIdAsync(configurationId); + if (configuration is null || configuration.OrganizationIntegrationId != integrationId) + { + throw new NotFoundException(); + } + if (!validator.ValidateConfiguration(integration.Type, updatedConfiguration)) + { + throw new BadRequestException($"Invalid Configuration and/or Filters for integration type {integration.Type}"); + } + + updatedConfiguration.Id = configuration.Id; + updatedConfiguration.CreationDate = configuration.CreationDate; + await configurationRepository.ReplaceAsync(updatedConfiguration); + + // If either old or new EventType is null (wildcard), invalidate all cached results + // for the specific integration + if (configuration.EventType == null || updatedConfiguration.EventType == null) + { + // Wildcard involved - invalidate all cached results for this org/integration + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + + return updatedConfiguration; + } + + // Both are specific event types - invalidate specific cache entries + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: configuration.EventType.Value + )); + + // If event type changed, also clear the new event type's cache + if (configuration.EventType != updatedConfiguration.EventType) + { + await cache.RemoveAsync( + EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integration.Type, + eventType: updatedConfiguration.EventType.Value + )); + } + + return updatedConfiguration; + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/CreateOrganizationIntegrationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/CreateOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..376451977c --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/CreateOrganizationIntegrationCommand.cs @@ -0,0 +1,38 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; + +/// +/// Command implementation for creating organization integrations with cache invalidation support. +/// +public class CreateOrganizationIntegrationCommand( + IOrganizationIntegrationRepository integrationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] + IFusionCache cache) + : ICreateOrganizationIntegrationCommand +{ + public async Task CreateAsync(OrganizationIntegration integration) + { + var existingIntegrations = await integrationRepository + .GetManyByOrganizationAsync(integration.OrganizationId); + if (existingIntegrations.Any(i => i.Type == integration.Type)) + { + throw new BadRequestException("An integration of this type already exists for this organization."); + } + + var created = await integrationRepository.CreateAsync(integration); + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: integration.OrganizationId, + integrationType: integration.Type + )); + + return created; + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/DeleteOrganizationIntegrationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/DeleteOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..614693cd82 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/DeleteOrganizationIntegrationCommand.cs @@ -0,0 +1,33 @@ +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; + +/// +/// Command implementation for deleting organization integrations with cache invalidation support. +/// +public class DeleteOrganizationIntegrationCommand( + IOrganizationIntegrationRepository integrationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache) + : IDeleteOrganizationIntegrationCommand +{ + public async Task DeleteAsync(Guid organizationId, Guid integrationId) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration is null || integration.OrganizationId != organizationId) + { + throw new NotFoundException(); + } + + await integrationRepository.DeleteAsync(integration); + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/GetOrganizationIntegrationsQuery.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/GetOrganizationIntegrationsQuery.cs new file mode 100644 index 0000000000..f7bbaadb4a --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/GetOrganizationIntegrationsQuery.cs @@ -0,0 +1,18 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; + +/// +/// Query implementation for retrieving organization integrations. +/// +public class GetOrganizationIntegrationsQuery(IOrganizationIntegrationRepository integrationRepository) + : IGetOrganizationIntegrationsQuery +{ + public async Task> GetManyByOrganizationAsync(Guid organizationId) + { + var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId); + return integrations.ToList(); + } +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/ICreateOrganizationIntegrationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/ICreateOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..e7b79eab13 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/ICreateOrganizationIntegrationCommand.cs @@ -0,0 +1,18 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; + +/// +/// Command interface for creating an OrganizationIntegration. +/// +public interface ICreateOrganizationIntegrationCommand +{ + /// + /// Creates a new organization integration. + /// + /// The OrganizationIntegration to create. + /// The created OrganizationIntegration. + /// Thrown when an integration + /// of the same type already exists for the organization. + Task CreateAsync(OrganizationIntegration integration); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IDeleteOrganizationIntegrationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IDeleteOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..be22b4e482 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IDeleteOrganizationIntegrationCommand.cs @@ -0,0 +1,16 @@ +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; + +/// +/// Command interface for deleting organization integrations. +/// +public interface IDeleteOrganizationIntegrationCommand +{ + /// + /// Deletes an organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration to delete. + /// Thrown when the integration does not exist + /// or does not belong to the specified organization. + Task DeleteAsync(Guid organizationId, Guid integrationId); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IGetOrganizationIntegrationsQuery.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IGetOrganizationIntegrationsQuery.cs new file mode 100644 index 0000000000..8cdea7f301 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IGetOrganizationIntegrationsQuery.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; + +/// +/// Query interface for retrieving organization integrations. +/// +public interface IGetOrganizationIntegrationsQuery +{ + /// + /// Retrieves all organization integrations for a specific organization. + /// + /// The unique identifier of the organization. + /// A list of organization integrations associated with the organization. + Task> GetManyByOrganizationAsync(Guid organizationId); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IUpdateOrganizationIntegrationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IUpdateOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..f40086600d --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/Interfaces/IUpdateOrganizationIntegrationCommand.cs @@ -0,0 +1,20 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; + +/// +/// Command interface for updating organization integrations. +/// +public interface IUpdateOrganizationIntegrationCommand +{ + /// + /// Updates an existing organization integration. + /// + /// The unique identifier of the organization. + /// The unique identifier of the integration to update. + /// The updated organization integration data. + /// The updated organization integration. + /// Thrown when the integration does not exist, + /// does not belong to the specified organization, or the integration type does not match. + Task UpdateAsync(Guid organizationId, Guid integrationId, OrganizationIntegration updatedIntegration); +} diff --git a/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/UpdateOrganizationIntegrationCommand.cs b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/UpdateOrganizationIntegrationCommand.cs new file mode 100644 index 0000000000..12a8620926 --- /dev/null +++ b/src/Core/AdminConsole/EventIntegrations/OrganizationIntegrations/UpdateOrganizationIntegrationCommand.cs @@ -0,0 +1,45 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; +using ZiggyCreatures.Caching.Fusion; + +namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations; + +/// +/// Command implementation for updating organization integrations with cache invalidation support. +/// +public class UpdateOrganizationIntegrationCommand( + IOrganizationIntegrationRepository integrationRepository, + [FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] + IFusionCache cache) + : IUpdateOrganizationIntegrationCommand +{ + public async Task UpdateAsync( + Guid organizationId, + Guid integrationId, + OrganizationIntegration updatedIntegration) + { + var integration = await integrationRepository.GetByIdAsync(integrationId); + if (integration is null || + integration.OrganizationId != organizationId || + integration.Type != updatedIntegration.Type) + { + throw new NotFoundException(); + } + + updatedIntegration.Id = integration.Id; + updatedIntegration.OrganizationId = integration.OrganizationId; + updatedIntegration.CreationDate = integration.CreationDate; + await integrationRepository.ReplaceAsync(updatedIntegration); + await cache.RemoveByTagAsync( + EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId: organizationId, + integrationType: integration.Type + )); + + return updatedIntegration; + } +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs index fe33c45156..c44e550d15 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs @@ -1,8 +1,8 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; -using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; @@ -36,13 +36,18 @@ public class IntegrationTemplateContext(EventMessage eventMessage) public string DateIso8601 => Date.ToString("o"); public string EventMessage => JsonSerializer.Serialize(Event); - public User? User { get; set; } + public OrganizationUserUserDetails? User { get; set; } public string? UserName => User?.Name; public string? UserEmail => User?.Email; + public OrganizationUserType? UserType => User?.Type; - public User? ActingUser { get; set; } + public OrganizationUserUserDetails? ActingUser { get; set; } public string? ActingUserName => ActingUser?.Name; public string? ActingUserEmail => ActingUser?.Email; + public OrganizationUserType? ActingUserType => ActingUser?.Type; + + public Group? Group { get; set; } + public string? GroupName => Group?.Name; public Organization? Organization { get; set; } public string? OrganizationName => Organization?.DisplayName(); diff --git a/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs index 820b65dbfd..0368678641 100644 --- a/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs @@ -53,4 +53,5 @@ public interface IProfileOrganizationDetails bool UseAdminSponsoredFamilies { get; set; } bool UseOrganizationDomains { get; set; } bool UseAutomaticUserConfirmation { get; set; } + bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs index 3c02a4f50b..7c8389c103 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs @@ -29,6 +29,7 @@ public class OrganizationAbility UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation; + UsePhishingBlocker = organization.UsePhishingBlocker; } public Guid Id { get; set; } @@ -51,4 +52,5 @@ public class OrganizationAbility public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs index 8d30bfc250..00b9280337 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -65,4 +65,5 @@ public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails public bool UseAdminSponsoredFamilies { get; set; } public bool? IsAdminInitiated { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs index 6d182e197f..00bac01f76 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserUserDetails.cs @@ -63,11 +63,6 @@ public class OrganizationUserUserDetails : IExternal, ITwoFactorProvidersUser, I return UserId; } - public bool GetPremium() - { - return Premium.GetValueOrDefault(false); - } - public Permissions GetPermissions() { return string.IsNullOrWhiteSpace(Permissions) ? null diff --git a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs index 84ff164943..484320c271 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/SelfHostedOrganizationDetails.cs @@ -154,6 +154,7 @@ public class SelfHostedOrganizationDetails : Organization Status = Status, UseRiskInsights = UseRiskInsights, UseAdminSponsoredFamilies = UseAdminSponsoredFamilies, + UsePhishingBlocker = UsePhishingBlocker, }; } } diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index 0d48f5cfa9..dcec028dcc 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -56,4 +56,5 @@ public class ProviderUserOrganizationDetails : IProfileOrganizationDetails public string? SsoExternalId { get; set; } public string? Permissions { get; set; } public string? ResetPasswordKey { get; set; } + public bool UsePhishingBlocker { get; set; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index 595e487580..e6cc3da2a2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.Context; @@ -25,8 +24,6 @@ public class VerifyOrganizationDomainCommand( IEventService eventService, IGlobalSettings globalSettings, ICurrentContext currentContext, - IFeatureService featureService, - ISavePolicyCommand savePolicyCommand, IVNextSavePolicyCommand vNextSavePolicyCommand, IMailService mailService, IOrganizationUserRepository organizationUserRepository, @@ -144,15 +141,8 @@ public class VerifyOrganizationDomainCommand( PerformedBy = actingUser }; - if (featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) - { - var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser); - await vNextSavePolicyCommand.SaveAsync(savePolicyModel); - } - else - { - await savePolicyCommand.SaveAsync(policyUpdate); - } + var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser); + await vNextSavePolicyCommand.SaveAsync(savePolicyModel); } private async Task SendVerifiedDomainUserEmailAsync(OrganizationDomain domain) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/README.md b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/README.md new file mode 100644 index 0000000000..063b2f6a5c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/README.md @@ -0,0 +1,22 @@ +# Automatic User Confirmation + +Owned by: admin-console + +Automatic confirmation requests are server driven events that are sent to the admin's client where via a background service the confirmation will occur. The basic model +for the workflow is as follows: + +- The Api server sends an invite email to a user. +- The user accepts the invite request, which is sent back to the Api server +- The Api server sends a push-notification with the OrganizationId and UserId to a client admin session. +- The Client performs the key exchange in the background and POSTs the ConfirmRequest back to the Api server +- The Api server runs the OrgUser_Confirm sproc to confirm the user in the DB + +This Feature has the following security measures in place in order to achieve our security goals: + +- The single organization exemption for admins/owners is removed for this policy. + - This is enforced by preventing enabling the policy and organization plan feature if there are non-compliant users +- Emergency access is removed for all organization users +- Automatic confirmation will only apply to the User role (You cannot auto confirm admins/owners to an organization) +- The organization has no members with the Provider user type. + - This will also prevent the policy and organization plan feature from being enabled + - This will prevent sending organization invites to provider users diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommand.cs new file mode 100644 index 0000000000..c7c80bd937 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/BulkResendOrganizationInvitesCommand.cs @@ -0,0 +1,69 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.AdminConsole.Utilities.DebuggingInstruments; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public class BulkResendOrganizationInvitesCommand : IBulkResendOrganizationInvitesCommand +{ + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; + private readonly ILogger _logger; + + public BulkResendOrganizationInvitesCommand( + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, + ILogger logger) + { + _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; + _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; + _logger = logger; + } + + public async Task>> BulkResendInvitesAsync( + Guid organizationId, + Guid? invitingUserId, + IEnumerable organizationUsersId) + { + var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId); + _logger.LogUserInviteStateDiagnostics(orgUsers); + + var org = await _organizationRepository.GetByIdAsync(organizationId); + if (org == null) + { + throw new NotFoundException(); + } + + var validUsers = new List(); + var result = new List>(); + + foreach (var orgUser in orgUsers) + { + if (orgUser.Status != OrganizationUserStatusType.Invited || orgUser.OrganizationId != organizationId) + { + result.Add(Tuple.Create(orgUser, "User invalid.")); + } + else + { + validUsers.Add(orgUser); + } + } + + if (validUsers.Any()) + { + await _sendOrganizationInvitesCommand.SendInvitesAsync( + new SendInvitesRequest(validUsers, org)); + + result.AddRange(validUsers.Select(u => Tuple.Create(u, ""))); + } + + return result; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IBulkResendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IBulkResendOrganizationInvitesCommand.cs new file mode 100644 index 0000000000..342a06fcf9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/IBulkResendOrganizationInvitesCommand.cs @@ -0,0 +1,20 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; + +public interface IBulkResendOrganizationInvitesCommand +{ + /// + /// Resend invites to multiple organization users in bulk. + /// + /// The ID of the organization. + /// The ID of the user who is resending the invites. + /// The IDs of the organization users to resend invites to. + /// A tuple containing the OrganizationUser and an error message (empty string if successful) + Task>> BulkResendInvitesAsync( + Guid organizationId, + Guid? invitingUserId, + IEnumerable organizationUsersId); +} + + diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs similarity index 95% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs index 01ad2f05d2..7b5541c3ce 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IRevokeOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/IRevokeOrganizationUserCommand.cs @@ -1,7 +1,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; public interface IRevokeOrganizationUserCommand { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs similarity index 99% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs index f24e0ae265..7aa67f0813 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v1/RevokeOrganizationUserCommand.cs @@ -7,7 +7,7 @@ using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1; public class RevokeOrganizationUserCommand( IEventService eventService, diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/Errors.cs new file mode 100644 index 0000000000..a30894c7d5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/Errors.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public record UserAlreadyRevoked() : BadRequestError("Already revoked."); +public record CannotRevokeYourself() : BadRequestError("You cannot revoke yourself."); +public record OnlyOwnersCanRevokeOwners() : BadRequestError("Only owners can revoke other owners."); +public record MustHaveConfirmedOwner() : BadRequestError("Organization must have at least one confirmed owner."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..e6471ad891 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public interface IRevokeOrganizationUserCommand +{ + Task> RevokeUsersAsync(RevokeOrganizationUsersRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserValidator.cs new file mode 100644 index 0000000000..1a5cfd2c46 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/IRevokeOrganizationUserValidator.cs @@ -0,0 +1,9 @@ +using Bit.Core.AdminConsole.Utilities.v2.Validation; +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public interface IRevokeOrganizationUserValidator +{ + Task>> ValidateAsync(RevokeOrganizationUsersValidationRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommand.cs new file mode 100644 index 0000000000..ca501277a7 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommand.cs @@ -0,0 +1,114 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public class RevokeOrganizationUserCommand( + IOrganizationUserRepository organizationUserRepository, + IEventService eventService, + IPushNotificationService pushNotificationService, + IRevokeOrganizationUserValidator validator, + TimeProvider timeProvider, + ILogger logger) + : IRevokeOrganizationUserCommand +{ + public async Task> RevokeUsersAsync(RevokeOrganizationUsersRequest request) + { + var validationRequest = await CreateValidationRequestsAsync(request); + + var results = await validator.ValidateAsync(validationRequest); + + var validUsers = results.Where(r => r.IsValid).Select(r => r.Request).ToList(); + + await RevokeValidUsersAsync(validUsers); + + await Task.WhenAll( + LogRevokedOrganizationUsersAsync(validUsers, request.PerformedBy), + SendPushNotificationsAsync(validUsers) + ); + + return results.Select(r => r.Match( + error => new BulkCommandResult(r.Request.Id, error), + _ => new BulkCommandResult(r.Request.Id, new None()) + )); + } + + private async Task CreateValidationRequestsAsync( + RevokeOrganizationUsersRequest request) + { + var organizationUserToRevoke = await organizationUserRepository + .GetManyAsync(request.OrganizationUserIdsToRevoke); + + return new RevokeOrganizationUsersValidationRequest( + request.OrganizationId, + request.OrganizationUserIdsToRevoke, + request.PerformedBy, + organizationUserToRevoke); + } + + private async Task RevokeValidUsersAsync(ICollection validUsers) + { + if (validUsers.Count == 0) + { + return; + } + + await organizationUserRepository.RevokeManyByIdAsync(validUsers.Select(u => u.Id)); + } + + private async Task LogRevokedOrganizationUsersAsync( + ICollection revokedUsers, + IActingUser actingUser) + { + if (revokedUsers.Count == 0) + { + return; + } + + var eventDate = timeProvider.GetUtcNow().UtcDateTime; + + if (actingUser is SystemUser { SystemUserType: not null }) + { + var revokeEventsWithSystem = revokedUsers + .Select(user => (user, EventType.OrganizationUser_Revoked, actingUser.SystemUserType!.Value, + (DateTime?)eventDate)) + .ToList(); + await eventService.LogOrganizationUserEventsAsync(revokeEventsWithSystem); + } + else + { + var revokeEvents = revokedUsers + .Select(user => (user, EventType.OrganizationUser_Revoked, (DateTime?)eventDate)) + .ToList(); + await eventService.LogOrganizationUserEventsAsync(revokeEvents); + } + } + + private async Task SendPushNotificationsAsync(ICollection revokedUsers) + { + var userIdsToNotify = revokedUsers + .Where(user => user.UserId.HasValue) + .Select(user => user.UserId!.Value) + .Distinct() + .ToList(); + + foreach (var userId in userIdsToNotify) + { + try + { + await pushNotificationService.PushSyncOrgKeysAsync(userId); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to send push notification for user {UserId}.", userId); + } + } + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersRequest.cs new file mode 100644 index 0000000000..56996ffb53 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersRequest.cs @@ -0,0 +1,17 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public record RevokeOrganizationUsersRequest( + Guid OrganizationId, + ICollection OrganizationUserIdsToRevoke, + IActingUser PerformedBy +); + +public record RevokeOrganizationUsersValidationRequest( + Guid OrganizationId, + ICollection OrganizationUserIdsToRevoke, + IActingUser PerformedBy, + ICollection OrganizationUsersToRevoke +) : RevokeOrganizationUsersRequest(OrganizationId, OrganizationUserIdsToRevoke, PerformedBy); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidator.cs new file mode 100644 index 0000000000..d2f47ed713 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidator.cs @@ -0,0 +1,39 @@ +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.Utilities.v2.Validation; +using Bit.Core.Entities; +using Bit.Core.Enums; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; + +public class RevokeOrganizationUsersValidator(IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery) + : IRevokeOrganizationUserValidator +{ + public async Task>> ValidateAsync( + RevokeOrganizationUsersValidationRequest request) + { + var hasRemainingOwner = await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(request.OrganizationId, + request.OrganizationUsersToRevoke.Select(x => x.Id) // users excluded because they are going to be revoked + ); + + return request.OrganizationUsersToRevoke.Select(x => + { + return x switch + { + _ when request.PerformedBy is not SystemUser + && x.UserId is not null + && x.UserId == request.PerformedBy.UserId => + Invalid(x, new CannotRevokeYourself()), + { Status: OrganizationUserStatusType.Revoked } => + Invalid(x, new UserAlreadyRevoked()), + { Type: OrganizationUserType.Owner } when !hasRemainingOwner => + Invalid(x, new MustHaveConfirmedOwner()), + { Type: OrganizationUserType.Owner } when !request.PerformedBy.IsOrganizationOwnerOrProvider => + Invalid(x, new OnlyOwnersCanRevokeOwners()), + + _ => Valid(x) + }; + }).ToList(); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateCommand.cs new file mode 100644 index 0000000000..85fbcd2740 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IOrganizationUpdateCommand.cs @@ -0,0 +1,15 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; + +public interface IOrganizationUpdateCommand +{ + /// + /// Updates an organization's information in the Bitwarden database and Stripe (if required). + /// Also optionally updates an organization's public-private keypair if it was not created with one. + /// On self-host, only the public-private keys will be updated because all other properties are fixed by the license file. + /// + /// The update request containing the details to be updated. + Task UpdateAsync(OrganizationUpdateRequest request); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs new file mode 100644 index 0000000000..64358f3048 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateCommand.cs @@ -0,0 +1,77 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Organizations.Services; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +public class OrganizationUpdateCommand( + IOrganizationService organizationService, + IOrganizationRepository organizationRepository, + IGlobalSettings globalSettings, + IOrganizationBillingService organizationBillingService +) : IOrganizationUpdateCommand +{ + public async Task UpdateAsync(OrganizationUpdateRequest request) + { + var organization = await organizationRepository.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + throw new NotFoundException(); + } + + if (globalSettings.SelfHosted) + { + return await UpdateSelfHostedAsync(organization, request); + } + + return await UpdateCloudAsync(organization, request); + } + + private async Task UpdateCloudAsync(Organization organization, OrganizationUpdateRequest request) + { + // Store original values for comparison + var originalName = organization.Name; + var originalBillingEmail = organization.BillingEmail; + + // Apply updates to organization + organization.UpdateDetails(request); + organization.BackfillPublicPrivateKeys(request); + await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + + // Update billing information in Stripe if required + await UpdateBillingAsync(organization, originalName, originalBillingEmail); + + return organization; + } + + /// + /// Self-host cannot update the organization details because they are set by the license file. + /// However, this command does offer a soft migration pathway for organizations without public and private keys. + /// If we remove this migration code in the future, this command and endpoint can become cloud only. + /// + private async Task UpdateSelfHostedAsync(Organization organization, OrganizationUpdateRequest request) + { + organization.BackfillPublicPrivateKeys(request); + await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); + return organization; + } + + private async Task UpdateBillingAsync(Organization organization, string originalName, string? originalBillingEmail) + { + // Update Stripe if name or billing email changed + var shouldUpdateBilling = originalName != organization.Name || + originalBillingEmail != organization.BillingEmail; + + if (!shouldUpdateBilling || string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) + { + return; + } + + await organizationBillingService.UpdateOrganizationNameAndEmail(organization); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateExtensions.cs new file mode 100644 index 0000000000..e90c39bc54 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateExtensions.cs @@ -0,0 +1,43 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +public static class OrganizationUpdateExtensions +{ + /// + /// Updates the organization name and/or billing email. + /// Any null property on the request object will be skipped. + /// + public static void UpdateDetails(this Organization organization, OrganizationUpdateRequest request) + { + // These values may or may not be sent by the client depending on the operation being performed. + // Skip any values not provided. + if (request.Name is not null) + { + organization.Name = request.Name; + } + + if (request.BillingEmail is not null) + { + organization.BillingEmail = request.BillingEmail.ToLowerInvariant().Trim(); + } + } + + /// + /// Updates the organization public and private keys if provided and not already set. + /// This is legacy code for old organizations that were not created with a public/private keypair. It is a soft + /// migration that will silently migrate organizations when they change their details. + /// + public static void BackfillPublicPrivateKeys(this Organization organization, OrganizationUpdateRequest request) + { + if (!string.IsNullOrWhiteSpace(request.PublicKey) && string.IsNullOrWhiteSpace(organization.PublicKey)) + { + organization.PublicKey = request.PublicKey; + } + + if (!string.IsNullOrWhiteSpace(request.EncryptedPrivateKey) && string.IsNullOrWhiteSpace(organization.PrivateKey)) + { + organization.PrivateKey = request.EncryptedPrivateKey; + } + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateRequest.cs new file mode 100644 index 0000000000..21d4948678 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Update/OrganizationUpdateRequest.cs @@ -0,0 +1,33 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update; + +/// +/// Request model for updating the name, billing email, and/or public-private keys for an organization (legacy migration code). +/// Any combination of these properties can be updated, so they are optional. If none are specified it will not update anything. +/// +public record OrganizationUpdateRequest +{ + /// + /// The ID of the organization to update. + /// + public required Guid OrganizationId { get; init; } + + /// + /// The new organization name to apply (optional, this is skipped if not provided). + /// + public string? Name { get; init; } + + /// + /// The new billing email address to apply (optional, this is skipped if not provided). + /// + public string? BillingEmail { get; init; } + + /// + /// The organization's public key to set (optional, only set if not already present on the organization). + /// + public string? PublicKey { get; init; } + + /// + /// The organization's encrypted private key to set (optional, only set if not already present on the organization). + /// + public string? EncryptedPrivateKey { get; init; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs index e2bca930d1..57140317e3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs @@ -4,6 +4,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models; +using Bit.Core.Platform.Push; using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; @@ -16,19 +18,22 @@ public class SavePolicyCommand : ISavePolicyCommand private readonly IReadOnlyDictionary _policyValidators; private readonly TimeProvider _timeProvider; private readonly IPostSavePolicySideEffect _postSavePolicySideEffect; + private readonly IPushNotificationService _pushNotificationService; public SavePolicyCommand(IApplicationCacheService applicationCacheService, IEventService eventService, IPolicyRepository policyRepository, IEnumerable policyValidators, TimeProvider timeProvider, - IPostSavePolicySideEffect postSavePolicySideEffect) + IPostSavePolicySideEffect postSavePolicySideEffect, + IPushNotificationService pushNotificationService) { _applicationCacheService = applicationCacheService; _eventService = eventService; _policyRepository = policyRepository; _timeProvider = timeProvider; _postSavePolicySideEffect = postSavePolicySideEffect; + _pushNotificationService = pushNotificationService; var policyValidatorsDict = new Dictionary(); foreach (var policyValidator in policyValidators) @@ -75,6 +80,8 @@ public class SavePolicyCommand : ISavePolicyCommand await _policyRepository.UpsertAsync(policy); await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated); + await PushPolicyUpdateToClients(policy.OrganizationId, policy); + return policy; } @@ -152,4 +159,17 @@ public class SavePolicyCommand : ISavePolicyCommand var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type); return (savedPoliciesDict, currentPolicy); } + + Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => this._pushNotificationService.PushAsync(new PushNotification + { + Type = PushType.PolicyChanged, + Target = NotificationTarget.Organization, + TargetId = organizationId, + ExcludeCurrentContext = false, + Payload = new SyncPolicyPushNotification + { + Policy = policy, + OrganizationId = organizationId + } + }); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs index 5d40cb211f..38e417d085 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/VNextSavePolicyCommand.cs @@ -5,6 +5,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Int using Bit.Core.AdminConsole.Repositories; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models; +using Bit.Core.Platform.Push; using Bit.Core.Services; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations; @@ -15,7 +17,8 @@ public class VNextSavePolicyCommand( IPolicyRepository policyRepository, IEnumerable policyUpdateEventHandlers, TimeProvider timeProvider, - IPolicyEventHandlerFactory policyEventHandlerFactory) + IPolicyEventHandlerFactory policyEventHandlerFactory, + IPushNotificationService pushNotificationService) : IVNextSavePolicyCommand { @@ -74,7 +77,7 @@ public class VNextSavePolicyCommand( policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime; await policyRepository.UpsertAsync(policy); - + await PushPolicyUpdateToClients(policyUpdateRequest.OrganizationId, policy); return policy; } @@ -192,4 +195,17 @@ public class VNextSavePolicyCommand( var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type); return savedPoliciesDict; } + + Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => pushNotificationService.PushAsync(new PushNotification + { + Type = PushType.PolicyChanged, + Target = NotificationTarget.Organization, + TargetId = organizationId, + ExcludeCurrentContext = false, + Payload = new SyncPolicyPushNotification + { + Policy = policy, + OrganizationId = organizationId + } + }); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index e0ca8d6f90..272fd8cee4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -35,6 +35,8 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } [Obsolete("Use AddPolicyUpdateEvents instead.")] @@ -53,6 +55,7 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs index c0d302df02..86c94147f4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs @@ -4,6 +4,7 @@ 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.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; @@ -17,26 +18,13 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; ///
  • All organization users are compliant with the Single organization policy
  • ///
  • No provider users exist
  • /// -/// -/// This class also performs side effects when the policy is being enabled or disabled. They are: -///
      -///
    • Sets the UseAutomaticUserConfirmation organization feature to match the policy update
    • -///
    /// public class AutomaticUserConfirmationPolicyEventHandler( IOrganizationUserRepository organizationUserRepository, - IProviderUserRepository providerUserRepository, - IPolicyRepository policyRepository, - IOrganizationRepository organizationRepository, - TimeProvider timeProvider) - : IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent + IProviderUserRepository providerUserRepository) + : IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent { public PolicyType Type => PolicyType.AutomaticUserConfirmation; - public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) => - await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy); - - private const string _singleOrgPolicyNotEnabledErrorMessage = - "The Single organization policy must be enabled before enabling the Automatically confirm invited users policy."; private const string _usersNotCompliantWithSingleOrgErrorMessage = "All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations."; @@ -61,27 +49,20 @@ public class AutomaticUserConfirmationPolicyEventHandler( public async Task ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) => await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy); - public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) - { - var organization = await organizationRepository.GetByIdAsync(policyUpdate.OrganizationId); - - if (organization is not null) - { - organization.UseAutomaticUserConfirmation = policyUpdate.Enabled; - organization.RevisionDate = timeProvider.GetUtcNow().UtcDateTime; - await organizationRepository.UpsertAsync(organization); - } - } + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => + Task.CompletedTask; private async Task ValidateEnablingPolicyAsync(Guid organizationId) { - var singleOrgValidationError = await ValidateSingleOrgPolicyComplianceAsync(organizationId); + var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); + + var singleOrgValidationError = await ValidateUserComplianceWithSingleOrgAsync(organizationId, organizationUsers); if (!string.IsNullOrWhiteSpace(singleOrgValidationError)) { return singleOrgValidationError; } - var providerValidationError = await ValidateNoProviderUsersAsync(organizationId); + var providerValidationError = await ValidateNoProviderUsersAsync(organizationUsers); if (!string.IsNullOrWhiteSpace(providerValidationError)) { return providerValidationError; @@ -90,42 +71,24 @@ public class AutomaticUserConfirmationPolicyEventHandler( return string.Empty; } - private async Task ValidateSingleOrgPolicyComplianceAsync(Guid organizationId) + private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId, + ICollection organizationUsers) { - var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg); - if (singleOrgPolicy is not { Enabled: true }) - { - return _singleOrgPolicyNotEnabledErrorMessage; - } - - return await ValidateUserComplianceWithSingleOrgAsync(organizationId); - } - - private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId) - { - var organizationUsers = (await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId)) - .Where(ou => ou.Status != OrganizationUserStatusType.Invited && - ou.Status != OrganizationUserStatusType.Revoked && - ou.UserId.HasValue) - .ToList(); - - if (organizationUsers.Count == 0) - { - return string.Empty; - } - var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync( organizationUsers.Select(ou => ou.UserId!.Value))) - .Any(uo => uo.OrganizationId != organizationId && - uo.Status != OrganizationUserStatusType.Invited); + .Any(uo => uo.OrganizationId != organizationId + && uo.Status != OrganizationUserStatusType.Invited); return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty; } - private async Task ValidateNoProviderUsersAsync(Guid organizationId) + private async Task ValidateNoProviderUsersAsync(ICollection organizationUsers) { - var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId); + var userIds = organizationUsers.Where(x => x.UserId is not null) + .Select(x => x.UserId!.Value); - return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty; + return (await providerUserRepository.GetManyByManyUsersAsync(userIds)).Count != 0 + ? _providerUsersExistErrorMessage + : string.Empty; } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs new file mode 100644 index 0000000000..92ba11f5a6 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/BlockClaimedDomainAccountCreationPolicyValidator.cs @@ -0,0 +1,59 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using Bit.Core.Services; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class BlockClaimedDomainAccountCreationPolicyValidator : IPolicyValidator, IPolicyValidationEvent +{ + private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; + private readonly IFeatureService _featureService; + + public BlockClaimedDomainAccountCreationPolicyValidator( + IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, + IFeatureService featureService) + { + _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; + _featureService = featureService; + } + + public PolicyType Type => PolicyType.BlockClaimedDomainAccountCreation; + + // No prerequisites - this policy stands alone + public IEnumerable RequiredPolicies => []; + + public async Task ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy) + { + return await ValidateAsync(policyRequest.PolicyUpdate, currentPolicy); + } + + public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + // Check if feature is enabled + if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)) + { + return "This feature is not enabled"; + } + + // Only validate when trying to ENABLE the policy + if (policyUpdate is { Enabled: true }) + { + // Check if organization has at least one verified domain + if (!await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)) + { + return "You must claim at least one domain to turn on this policy"; + } + } + + // Disabling the policy is always allowed + return string.Empty; + } + + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + => Task.CompletedTask; +} diff --git a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs index 0a774cf395..fb42ffa000 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationIntegrationConfigurationRepository.cs @@ -6,10 +6,23 @@ namespace Bit.Core.Repositories; public interface IOrganizationIntegrationConfigurationRepository : IRepository { - Task> GetConfigurationDetailsAsync( + /// + /// Retrieve the list of available configuration details for a specific event for the organization and + /// integration type.
    + ///
    + /// Note: This returns all configurations that match the event type explicitly and + /// all the configurations that have a null event type - null event type is considered a + /// wildcard that matches all events. + /// + ///
    + /// The specific event type + /// The id of the organization + /// The integration type + /// A List of that match + Task> GetManyByEventTypeOrganizationIdIntegrationType( + EventType eventType, Guid organizationId, - IntegrationType integrationType, - EventType eventType); + IntegrationType integrationType); Task> GetAllConfigurationDetailsAsync(); diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs index bedb9d49ee..41622c24b7 100644 --- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs @@ -97,4 +97,15 @@ public interface IOrganizationUserRepository : IRepositoryAccepted OrganizationUser to confirm /// True, if the user was updated. False, if not performed. Task ConfirmOrganizationUserAsync(AcceptedOrganizationUserToConfirm organizationUserToConfirm); + + /// + /// Returns the OrganizationUserUserDetails if found. + /// + /// The id of the organization + /// The id of the User to fetch + /// OrganizationUserUserDetails of the specified user or null if not found + /// + /// Similar to GetByOrganizationAsync, but returns the user details. + /// + Task GetDetailsByOrganizationIdUserIdAsync(Guid organizationId, Guid userId); } diff --git a/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs b/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs index 7bc4125778..0a640b7530 100644 --- a/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs +++ b/src/Core/AdminConsole/Repositories/IProviderUserRepository.cs @@ -12,6 +12,7 @@ public interface IProviderUserRepository : IRepository Task GetCountByProviderAsync(Guid providerId, string email, bool onlyRegisteredUsers); Task> GetManyAsync(IEnumerable ids); Task> GetManyByUserAsync(Guid userId); + Task> GetManyByManyUsersAsync(IEnumerable userIds); Task GetByProviderUserAsync(Guid providerId, Guid userId); Task> GetManyByProviderAsync(Guid providerId, ProviderUserType? type = null); Task> GetManyDetailsByProviderAsync(Guid providerId, ProviderUserStatusType? status = null); diff --git a/src/Core/AdminConsole/Services/IOrganizationIntegrationConfigurationValidator.cs b/src/Core/AdminConsole/Services/IOrganizationIntegrationConfigurationValidator.cs new file mode 100644 index 0000000000..48346cbae7 --- /dev/null +++ b/src/Core/AdminConsole/Services/IOrganizationIntegrationConfigurationValidator.cs @@ -0,0 +1,17 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Services; + +public interface IOrganizationIntegrationConfigurationValidator +{ + /// + /// Validates that the configuration is valid for the given integration type. The configuration must + /// include a Configuration that is valid for the type, valid Filters, and a non-empty Template + /// to pass validation. + /// + /// The type of integration + /// The OrganizationIntegrationConfiguration to validate + /// True if valid, false otherwise + bool ValidateConfiguration(IntegrationType integrationType, OrganizationIntegrationConfiguration configuration); +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs index 8423652eb8..b4246884f7 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs @@ -1,10 +1,16 @@ using System.Text.Json; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities; using Bit.Core.Enums; using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Core.Services; @@ -12,25 +18,17 @@ public class EventIntegrationHandler( IntegrationType integrationType, IEventIntegrationPublisher eventIntegrationPublisher, IIntegrationFilterService integrationFilterService, - IIntegrationConfigurationDetailsCache configurationCache, - IUserRepository userRepository, + IFusionCache cache, + IOrganizationIntegrationConfigurationRepository configurationRepository, + IGroupRepository groupRepository, IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, ILogger> logger) : IEventMessageHandler { public async Task HandleEventAsync(EventMessage eventMessage) { - if (eventMessage.OrganizationId is not Guid organizationId) - { - return; - } - - var configurations = configurationCache.GetConfigurationDetails( - organizationId, - integrationType, - eventMessage.Type); - - foreach (var configuration in configurations) + foreach (var configuration in await GetConfigurationDetailsListAsync(eventMessage)) { try { @@ -57,7 +55,7 @@ public class EventIntegrationHandler( { IntegrationType = integrationType, MessageId = messageId.ToString(), - OrganizationId = organizationId.ToString(), + OrganizationId = eventMessage.OrganizationId?.ToString(), Configuration = config, RenderedTemplate = renderedTemplate, RetryCount = 0, @@ -85,25 +83,83 @@ public class EventIntegrationHandler( } } - private async Task BuildContextAsync(EventMessage eventMessage, string template) + internal async Task BuildContextAsync(EventMessage eventMessage, string template) { + // Note: All of these cache calls use the default options, including TTL of 30 minutes + var context = new IntegrationTemplateContext(eventMessage); + if (IntegrationTemplateProcessor.TemplateRequiresGroup(template) && eventMessage.GroupId.HasValue) + { + context.Group = await cache.GetOrSetAsync( + key: EventIntegrationsCacheConstants.BuildCacheKeyForGroup(eventMessage.GroupId.Value), + factory: async _ => await groupRepository.GetByIdAsync(eventMessage.GroupId.Value) + ); + } + + if (eventMessage.OrganizationId is not Guid organizationId) + { + return context; + } + if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue) { - context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value); + context.User = await GetUserFromCacheAsync(organizationId, eventMessage.UserId.Value); } if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue) { - context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value); + context.ActingUser = await GetUserFromCacheAsync(organizationId, eventMessage.ActingUserId.Value); } - if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue) + if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template)) { - context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value); + context.Organization = await cache.GetOrSetAsync( + key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(organizationId), + factory: async _ => await organizationRepository.GetByIdAsync(organizationId) + ); } return context; } + + private async Task> GetConfigurationDetailsListAsync(EventMessage eventMessage) + { + if (eventMessage.OrganizationId is not Guid organizationId) + { + return []; + } + + List configurations = []; + + var integrationTag = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration( + organizationId, + integrationType + ); + + configurations.AddRange(await cache.GetOrSetAsync>( + key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails( + organizationId: organizationId, + integrationType: integrationType, + eventType: eventMessage.Type), + factory: async _ => await configurationRepository.GetManyByEventTypeOrganizationIdIntegrationType( + eventType: eventMessage.Type, + organizationId: organizationId, + integrationType: integrationType), + options: new FusionCacheEntryOptions( + duration: EventIntegrationsCacheConstants.DurationForOrganizationIntegrationConfigurationDetails), + tags: [integrationTag] + )); + + return configurations; + } + + private async Task GetUserFromCacheAsync(Guid organizationId, Guid userId) => + await cache.GetOrSetAsync( + key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(organizationId, userId), + factory: async _ => await organizationUserRepository.GetDetailsByOrganizationIdUserIdAsync( + organizationId: organizationId, + userId: userId + ) + ); } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs deleted file mode 100644 index a63efac62f..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationConfigurationDetailsCacheService.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Diagnostics; -using Bit.Core.Enums; -using Bit.Core.Models.Data.Organizations; -using Bit.Core.Repositories; -using Bit.Core.Settings; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Services; - -public class IntegrationConfigurationDetailsCacheService : BackgroundService, IIntegrationConfigurationDetailsCache -{ - private readonly record struct IntegrationCacheKey(Guid OrganizationId, IntegrationType IntegrationType, EventType? EventType); - private readonly IOrganizationIntegrationConfigurationRepository _repository; - private readonly ILogger _logger; - private readonly TimeSpan _refreshInterval; - private Dictionary> _cache = new(); - - public IntegrationConfigurationDetailsCacheService( - IOrganizationIntegrationConfigurationRepository repository, - GlobalSettings globalSettings, - ILogger logger) - { - _repository = repository; - _logger = logger; - _refreshInterval = TimeSpan.FromMinutes(globalSettings.EventLogging.IntegrationCacheRefreshIntervalMinutes); - } - - public List GetConfigurationDetails( - Guid organizationId, - IntegrationType integrationType, - EventType eventType) - { - var specificKey = new IntegrationCacheKey(organizationId, integrationType, eventType); - var allEventsKey = new IntegrationCacheKey(organizationId, integrationType, null); - - var results = new List(); - - if (_cache.TryGetValue(specificKey, out var specificConfigs)) - { - results.AddRange(specificConfigs); - } - if (_cache.TryGetValue(allEventsKey, out var fallbackConfigs)) - { - results.AddRange(fallbackConfigs); - } - - return results; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - await RefreshAsync(); - - var timer = new PeriodicTimer(_refreshInterval); - while (await timer.WaitForNextTickAsync(stoppingToken)) - { - await RefreshAsync(); - } - } - - internal async Task RefreshAsync() - { - var stopwatch = Stopwatch.StartNew(); - try - { - var newCache = (await _repository.GetAllConfigurationDetailsAsync()) - .GroupBy(x => new IntegrationCacheKey(x.OrganizationId, x.IntegrationType, x.EventType)) - .ToDictionary(g => g.Key, g => g.ToList()); - _cache = newCache; - - stopwatch.Stop(); - _logger.LogInformation( - "[IntegrationConfigurationDetailsCacheService] Refreshed successfully: {Count} entries in {Duration}ms", - newCache.Count, - stopwatch.Elapsed.TotalMilliseconds); - } - catch (Exception ex) - { - _logger.LogError("[IntegrationConfigurationDetailsCacheService] Refresh failed: {ex}", ex); - } - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md index 7570d47211..f9de5b9778 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md @@ -295,33 +295,60 @@ graph TD ``` ## Caching -To reduce database load and improve performance, integration configurations are cached in-memory as a Dictionary -with a periodic load of all configurations. Without caching, each incoming `EventMessage` would trigger a database +To reduce database load and improve performance, event integrations uses its own named extended cache (see +[CACHING in Utilities](https://github.com/bitwarden/server/blob/main/src/Core/Utilities/CACHING.md) +for more information). Without caching, for instance, each incoming `EventMessage` would trigger a database query to retrieve the relevant `OrganizationIntegrationConfigurationDetails`. -By loading all configurations into memory on a fixed interval, we ensure: +### `EventIntegrationsCacheConstants` -- Consistent performance for reads. -- Reduced database pressure. -- Predictable refresh timing, independent of event activity. +`EventIntegrationsCacheConstants` allows the code to have strongly typed references to a number of cache-related +details when working with the extended cache. The cache name and all cache keys and tags are programmatically accessed +from `EventIntegrationsCacheConstants` rather than simple strings. For instance, +`EventIntegrationsCacheConstants.CacheName` is used in the cache setup, keyed services, dependency injection, etc., +rather than using a string literal (i.e. "EventIntegrations") in code. -### Architecture / Design +### `OrganizationIntegrationConfigurationDetails` -- The cache is read-only for consumers. It is only updated in bulk by a background refresh process. -- The cache is fully replaced on each refresh to avoid locking or partial state. +- This is one of the most actively used portions of the architecture because any event that has an associated + organization requires a check of the configurations to determine if we need to fire off an integration. +- By using the extended cache, all reads are hitting the L1 or L2 cache before needing to access the database. - Reads return a `List` for a given key or an empty list if no match exists. -- Failures or delays in the loading process do not affect the existing cache state. The cache will continue serving - the last known good state until the update replaces the whole cache. +- The TTL is set very high on these records (1 day). This is because when the admin API makes any changes, it + tells the cache to remove that key. This propagates to the event listening code via the extended cache backplane, + which means that the cache is then expired and the next read will fetch the new values. This allows us to have + a high TTL and avoid needing to refresh values except when necessary. -### Background Refresh +#### Tagging per integration -A hosted service (`IntegrationConfigurationDetailsCacheService`) runs in the background and: +- Each entry in the cache (which again, returns `List`) is tagged with + the organization id and the integration type. +- This allows us to remove all of a given organization's configuration details for an integration when the admin + makes changes at the integration level. + - For instance, if there were 5 events configured for a given organization's webhook and the admin changed the URL + at the integration level, the updates would need to be propagated or else the cache will continue returning the + stale URL. + - By tagging each of the entries, the API can ask the extended cache to remove all the entries for a given + organization integration in one call. The cache will handle dropping / refreshing these entries in a + performant way. +- There are two places in the code that are both aware of the tagging functionality + - The `EventIntegrationHandler` must use the tag when fetching relevant configuration details. This tells the cache + to store the entry with the tag when it successfully loads from the repository. + - The `CreateOrganizationIntegrationCommand`, `UpdateOrganizationIntegrationCommand`, and + `DeleteOrganizationIntegrationCommand` commands need to use the tag to remove all the tagged entries when an admin + creates, updates, or deletes an integration. + - To ensure both places are synchronized on how to tag entries, they both use + `EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration` to build the tag. -- Loads all configuration records at application startup. -- Refreshes the cache on a configurable interval. -- Logs timing and entry count on success. -- Logs exceptions on failure without disrupting application flow. +### Template Properties + +- The `IntegrationTemplateProcessor` supports some properties that require an additional lookup. For instance, + the `UserId` is provided as part of the `EventMessage`, but `UserName` means an additional lookup to map the user + id to the actual name. +- The properties for a `User` (which includes `ActingUser`), `Group`, and `Organization` are cached via the + extended cache with a default TTL of 30 minutes. +- This is cached in both the L1 (Memory) and L2 (Redis) and will be automatically refreshed as needed. # Building a new integration diff --git a/src/Core/AdminConsole/Services/OrganizationFactory.cs b/src/Core/AdminConsole/Services/OrganizationFactory.cs index f5df3327b1..0c64a27431 100644 --- a/src/Core/AdminConsole/Services/OrganizationFactory.cs +++ b/src/Core/AdminConsole/Services/OrganizationFactory.cs @@ -62,6 +62,7 @@ public static class OrganizationFactory UseAdminSponsoredFamilies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAdminSponsoredFamilies), UseAutomaticUserConfirmation = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseAutomaticUserConfirmation), + UsePhishingBlocker = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePhishingBlocker), }; public static Organization Create( @@ -111,6 +112,7 @@ public static class OrganizationFactory UseRiskInsights = license.UseRiskInsights, UseOrganizationDomains = license.UseOrganizationDomains, UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies, - UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation + UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation, + UsePhishingBlocker = license.UsePhishingBlocker, }; } diff --git a/src/Core/AdminConsole/Services/OrganizationIntegrationConfigurationValidator.cs b/src/Core/AdminConsole/Services/OrganizationIntegrationConfigurationValidator.cs new file mode 100644 index 0000000000..2769565675 --- /dev/null +++ b/src/Core/AdminConsole/Services/OrganizationIntegrationConfigurationValidator.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.Services; + +public class OrganizationIntegrationConfigurationValidator : IOrganizationIntegrationConfigurationValidator +{ + public bool ValidateConfiguration(IntegrationType integrationType, + OrganizationIntegrationConfiguration configuration) + { + // Validate template is present + if (string.IsNullOrWhiteSpace(configuration.Template)) + { + return false; + } + // If Filters are present, they must be valid + if (!IsFiltersValid(configuration.Filters)) + { + return false; + } + + switch (integrationType) + { + case IntegrationType.CloudBillingSync or IntegrationType.Scim: + return false; + case IntegrationType.Slack: + return IsConfigurationValid(configuration.Configuration); + case IntegrationType.Webhook: + return IsConfigurationValid(configuration.Configuration); + case IntegrationType.Hec: + case IntegrationType.Datadog: + case IntegrationType.Teams: + return configuration.Configuration is null; + default: + return false; + } + } + + private static bool IsConfigurationValid(string? configuration) + { + if (string.IsNullOrWhiteSpace(configuration)) + { + return false; + } + + try + { + var config = JsonSerializer.Deserialize(configuration); + return config is not null; + } + catch + { + return false; + } + } + + private static bool IsFiltersValid(string? filters) + { + if (filters is null) + { + return true; + } + + try + { + var filterGroup = JsonSerializer.Deserialize(filters); + return filterGroup is not null; + } + catch + { + return false; + } + } +} diff --git a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs index b561e58a86..7fc8013c15 100644 --- a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs +++ b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; namespace Bit.Core.AdminConsole.Utilities; @@ -26,7 +24,7 @@ public static partial class IntegrationTemplateProcessor return match.Value; // Return unknown keys as keys - i.e. #Key# } - return property?.GetValue(values)?.ToString() ?? ""; + return property.GetValue(values)?.ToString() ?? string.Empty; }); } @@ -38,7 +36,8 @@ public static partial class IntegrationTemplateProcessor } return template.Contains("#UserName#", StringComparison.Ordinal) - || template.Contains("#UserEmail#", StringComparison.Ordinal); + || template.Contains("#UserEmail#", StringComparison.Ordinal) + || template.Contains("#UserType#", StringComparison.Ordinal); } public static bool TemplateRequiresActingUser(string template) @@ -49,7 +48,18 @@ public static partial class IntegrationTemplateProcessor } return template.Contains("#ActingUserName#", StringComparison.Ordinal) - || template.Contains("#ActingUserEmail#", StringComparison.Ordinal); + || template.Contains("#ActingUserEmail#", StringComparison.Ordinal) + || template.Contains("#ActingUserType#", StringComparison.Ordinal); + } + + public static bool TemplateRequiresGroup(string template) + { + if (string.IsNullOrEmpty(template)) + { + return false; + } + + return template.Contains("#GroupName#", StringComparison.Ordinal); } public static bool TemplateRequiresOrganization(string template) diff --git a/src/Core/Auth/Attributes/MarketingInitiativeValidationAttribute.cs b/src/Core/Auth/Attributes/MarketingInitiativeValidationAttribute.cs new file mode 100644 index 0000000000..bcc4b851c0 --- /dev/null +++ b/src/Core/Auth/Attributes/MarketingInitiativeValidationAttribute.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Auth.Models.Api.Request.Accounts; + +namespace Bit.Core.Auth.Attributes; + +public class MarketingInitiativeValidationAttribute : ValidationAttribute +{ + private static readonly string[] _acceptedValues = [MarketingInitiativeConstants.Premium]; + + public MarketingInitiativeValidationAttribute() + { + ErrorMessage = $"Marketing initiative type must be one of: {string.Join(", ", _acceptedValues)}"; + } + + public override bool IsValid(object? value) + { + if (value == null) + { + return true; + } + + if (value is not string str) + { + return false; + } + + return _acceptedValues.Contains(str); + } +} diff --git a/src/Core/Auth/Entities/AuthRequest.cs b/src/Core/Auth/Entities/AuthRequest.cs index 2117c575c0..38dc0534c1 100644 --- a/src/Core/Auth/Entities/AuthRequest.cs +++ b/src/Core/Auth/Entities/AuthRequest.cs @@ -49,11 +49,9 @@ public class AuthRequest : ITableObject public bool IsExpired() { - // TODO: PM-24252 - consider using TimeProvider for better mocking in tests return GetExpirationDate() < DateTime.UtcNow; } - // TODO: PM-24252 - this probably belongs in a service. public bool IsValidForAuthentication(Guid userId, string password) { diff --git a/src/Core/Auth/LoginFeatures/LoginServiceCollectionExtensions.cs b/src/Core/Auth/LoginFeatures/LoginServiceCollectionExtensions.cs deleted file mode 100644 index f8caad448b..0000000000 --- a/src/Core/Auth/LoginFeatures/LoginServiceCollectionExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Bit.Core.Auth.LoginFeatures.PasswordlessLogin; -using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; -using Microsoft.Extensions.DependencyInjection; - -namespace Bit.Core.Auth.LoginFeatures; - -public static class LoginServiceCollectionExtensions -{ - public static void AddLoginServices(this IServiceCollection services) - { - services.AddScoped(); - } -} - diff --git a/src/Core/Auth/LoginFeatures/PasswordlessLogin/Interfaces/IVerifyAuthRequest.cs b/src/Core/Auth/LoginFeatures/PasswordlessLogin/Interfaces/IVerifyAuthRequest.cs deleted file mode 100644 index e5da1b06d8..0000000000 --- a/src/Core/Auth/LoginFeatures/PasswordlessLogin/Interfaces/IVerifyAuthRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; - -public interface IVerifyAuthRequestCommand -{ - Task VerifyAuthRequestAsync(Guid authRequestId, string accessCode); -} diff --git a/src/Core/Auth/LoginFeatures/PasswordlessLogin/VerifyAuthRequest.cs b/src/Core/Auth/LoginFeatures/PasswordlessLogin/VerifyAuthRequest.cs deleted file mode 100644 index 7def7fea76..0000000000 --- a/src/Core/Auth/LoginFeatures/PasswordlessLogin/VerifyAuthRequest.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; -using Bit.Core.Repositories; -using Bit.Core.Utilities; - -namespace Bit.Core.Auth.LoginFeatures.PasswordlessLogin; - -public class VerifyAuthRequestCommand : IVerifyAuthRequestCommand -{ - private readonly IAuthRequestRepository _authRequestRepository; - - public VerifyAuthRequestCommand(IAuthRequestRepository authRequestRepository) - { - _authRequestRepository = authRequestRepository; - } - - public async Task VerifyAuthRequestAsync(Guid authRequestId, string accessCode) - { - var authRequest = await _authRequestRepository.GetByIdAsync(authRequestId); - if (authRequest == null || !CoreHelpers.FixedTimeEquals(authRequest.AccessCode, accessCode)) - { - return false; - } - return true; - } -} diff --git a/src/Core/Auth/Models/Api/Request/Accounts/MarketingInitiativeConstants.cs b/src/Core/Auth/Models/Api/Request/Accounts/MarketingInitiativeConstants.cs new file mode 100644 index 0000000000..ab2d252dc8 --- /dev/null +++ b/src/Core/Auth/Models/Api/Request/Accounts/MarketingInitiativeConstants.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Auth.Models.Api.Request.Accounts; + +public static class MarketingInitiativeConstants +{ + /// + /// Indicates that the user began the registration process on a marketing page designed + /// to streamline users who intend to setup a premium subscription after registration. + /// + public const string Premium = "premium"; +} diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs index 75a4da081a..638565ecfe 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs @@ -1,5 +1,6 @@ #nullable enable using System.ComponentModel.DataAnnotations; +using Bit.Core.Auth.Attributes; using Bit.Core.Utilities; namespace Bit.Core.Auth.Models.Api.Request.Accounts; @@ -11,4 +12,6 @@ public class RegisterSendVerificationEmailRequestModel [StringLength(256)] public required string Email { get; set; } public bool ReceiveMarketingEmails { get; set; } + [MarketingInitiativeValidation] + public string? FromMarketing { get; set; } } diff --git a/src/Core/Auth/Models/ITwoFactorProvidersUser.cs b/src/Core/Auth/Models/ITwoFactorProvidersUser.cs index 5cf137b76f..816d460572 100644 --- a/src/Core/Auth/Models/ITwoFactorProvidersUser.cs +++ b/src/Core/Auth/Models/ITwoFactorProvidersUser.cs @@ -1,14 +1,14 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Enums; using Bit.Core.Services; namespace Bit.Core.Auth.Models; +/// +/// An interface representing a user entity that supports two-factor providers +/// public interface ITwoFactorProvidersUser { - string TwoFactorProviders { get; } + string? TwoFactorProviders { get; } /// /// Get the two factor providers for the user. Currently it can be assumed providers are enabled /// if they exists in the dictionary. When two factor providers are disabled they are removed @@ -16,7 +16,10 @@ public interface ITwoFactorProvidersUser /// /// /// Dictionary of providers with the type enum as the key - Dictionary GetTwoFactorProviders(); + Dictionary? GetTwoFactorProviders(); + /// + /// The unique `UserId` of the user entity for which there are two-factor providers configured. + /// + /// The unique identifier for the user Guid? GetUserId(); - bool GetPremium(); } diff --git a/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs index fe42093111..5c0efeb73f 100644 --- a/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs +++ b/src/Core/Auth/Models/Mail/RegisterVerifyEmail.cs @@ -15,11 +15,13 @@ public class RegisterVerifyEmail : BaseMailModel // so we must land on a redirect connector which will redirect to the finish signup page. // Note 3: The use of a fragment to indicate the redirect url is to prevent the query string from being logged by // proxies and servers. It also helps reduce open redirect vulnerabilities. - public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true", + public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true{3}", WebVaultUrl, Token, - Email); + Email, + !string.IsNullOrEmpty(FromMarketing) ? $"&fromMarketing={FromMarketing}" : string.Empty); public string Token { get; set; } public string Email { get; set; } + public string FromMarketing { get; set; } } diff --git a/src/Core/Auth/Services/Implementations/SsoConfigService.cs b/src/Core/Auth/Services/Implementations/SsoConfigService.cs index 1a35585b2c..0cb8b68042 100644 --- a/src/Core/Auth/Services/Implementations/SsoConfigService.cs +++ b/src/Core/Auth/Services/Implementations/SsoConfigService.cs @@ -5,7 +5,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; @@ -26,8 +25,6 @@ public class SsoConfigService : ISsoConfigService private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IEventService _eventService; - private readonly IFeatureService _featureService; - private readonly ISavePolicyCommand _savePolicyCommand; private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; public SsoConfigService( @@ -36,8 +33,6 @@ public class SsoConfigService : ISsoConfigService IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IEventService eventService, - IFeatureService featureService, - ISavePolicyCommand savePolicyCommand, IVNextSavePolicyCommand vNextSavePolicyCommand) { _ssoConfigRepository = ssoConfigRepository; @@ -45,8 +40,6 @@ public class SsoConfigService : ISsoConfigService _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _eventService = eventService; - _featureService = featureService; - _savePolicyCommand = savePolicyCommand; _vNextSavePolicyCommand = vNextSavePolicyCommand; } @@ -97,19 +90,10 @@ public class SsoConfigService : ISsoConfigService Enabled = true }; - if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) - { - var performedBy = new SystemUser(EventSystemUser.Unknown); - await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(singleOrgPolicy, performedBy)); - await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(resetPasswordPolicy, performedBy)); - await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(requireSsoPolicy, performedBy)); - } - else - { - await _savePolicyCommand.SaveAsync(singleOrgPolicy); - await _savePolicyCommand.SaveAsync(resetPasswordPolicy); - await _savePolicyCommand.SaveAsync(requireSsoPolicy); - } + var performedBy = new SystemUser(EventSystemUser.Unknown); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(singleOrgPolicy, performedBy)); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(resetPasswordPolicy, performedBy)); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(requireSsoPolicy, performedBy)); } await LogEventsAsync(config, oldConfig); diff --git a/src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs b/src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs new file mode 100644 index 0000000000..c932eb0c34 --- /dev/null +++ b/src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs @@ -0,0 +1,23 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Auth.Sso; + +/// +/// Query to retrieve the SSO organization identifier that a user is a confirmed member of. +/// +public interface IUserSsoOrganizationIdentifierQuery +{ + /// + /// Retrieves the SSO organization identifier for a confirmed organization user. + /// If there is more than one organization a User is associated with, we return null. If there are more than one + /// organization there is no way to know which organization the user wishes to authenticate with. + /// Owners and Admins who are not subject to the SSO required policy cannot utilize this flow, since they may have + /// multiple organizations with different SSO configurations. + /// + /// The ID of the to retrieve the SSO organization for. _Not_ an . + /// + /// The organization identifier if the user is a confirmed member of an organization with SSO configured, + /// otherwise null + /// + Task GetSsoOrganizationIdentifierAsync(Guid userId); +} diff --git a/src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs b/src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs new file mode 100644 index 0000000000..c0751e1f1a --- /dev/null +++ b/src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Repositories; + +namespace Bit.Core.Auth.Sso; + +/// +/// TODO : PM-28846 review data structures as they relate to this query +/// Query to retrieve the SSO organization identifier that a user is a confirmed member of. +/// +public class UserSsoOrganizationIdentifierQuery( + IOrganizationUserRepository _organizationUserRepository, + IOrganizationRepository _organizationRepository) : IUserSsoOrganizationIdentifierQuery +{ + /// + public async Task GetSsoOrganizationIdentifierAsync(Guid userId) + { + // Get all confirmed organization memberships for the user + var organizationUsers = await _organizationUserRepository.GetManyByUserAsync(userId); + + // we can only confidently return the correct SsoOrganizationIdentifier if there is exactly one Organization. + // The user must also be in the Confirmed status. + var confirmedOrgUsers = organizationUsers.Where(ou => ou.Status == OrganizationUserStatusType.Confirmed); + if (confirmedOrgUsers.Count() != 1) + { + return null; + } + + var confirmedOrgUser = confirmedOrgUsers.Single(); + var organization = await _organizationRepository.GetByIdAsync(confirmedOrgUser.OrganizationId); + + if (organization == null) + { + return null; + } + + return organization.Identifier; + } +} diff --git a/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs index b623b8cab3..2a224b9eb9 100644 --- a/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs @@ -3,5 +3,5 @@ namespace Bit.Core.Auth.UserFeatures.Registration; public interface ISendVerificationEmailForRegistrationCommand { - public Task Run(string email, string? name, bool receiveMarketingEmails); + public Task Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing); } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 4aaa9360a0..be85a858a3 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -5,6 +5,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -15,16 +16,20 @@ using Bit.Core.Tokens; using Bit.Core.Utilities; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace Bit.Core.Auth.UserFeatures.Registration.Implementations; public class RegisterUserCommand : IRegisterUserCommand { + private readonly ILogger _logger; private readonly IGlobalSettings _globalSettings; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IPolicyRepository _policyRepository; + private readonly IOrganizationDomainRepository _organizationDomainRepository; + private readonly IFeatureService _featureService; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; @@ -37,28 +42,32 @@ public class RegisterUserCommand : IRegisterUserCommand private readonly IValidateRedemptionTokenCommand _validateRedemptionTokenCommand; private readonly IDataProtectorTokenFactory _emergencyAccessInviteTokenDataFactory; - private readonly IFeatureService _featureService; private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator."; public RegisterUserCommand( + ILogger logger, IGlobalSettings globalSettings, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, IPolicyRepository policyRepository, + IOrganizationDomainRepository organizationDomainRepository, + IFeatureService featureService, IDataProtectionProvider dataProtectionProvider, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, IUserService userService, IMailService mailService, IValidateRedemptionTokenCommand validateRedemptionTokenCommand, - IDataProtectorTokenFactory emergencyAccessInviteTokenDataFactory, - IFeatureService featureService) + IDataProtectorTokenFactory emergencyAccessInviteTokenDataFactory) { + _logger = logger; _globalSettings = globalSettings; _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; _policyRepository = policyRepository; + _organizationDomainRepository = organizationDomainRepository; + _featureService = featureService; _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( "OrganizationServiceDataProtector"); @@ -77,6 +86,8 @@ public class RegisterUserCommand : IRegisterUserCommand public async Task RegisterUser(User user) { + await ValidateEmailDomainNotBlockedAsync(user.Email); + var result = await _userService.CreateUserAsync(user); if (result == IdentityResult.Success) { @@ -102,6 +113,11 @@ public class RegisterUserCommand : IRegisterUserCommand { TryValidateOrgInviteToken(orgInviteToken, orgUserId, user); var orgUser = await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user); + if (orgUser == null && orgUserId.HasValue) + { + throw new BadRequestException("Invalid organization user invitation."); + } + await ValidateEmailDomainNotBlockedAsync(user.Email, orgUser?.OrganizationId); user.ApiKey = CoreHelpers.SecureRandomString(30); @@ -265,6 +281,8 @@ public class RegisterUserCommand : IRegisterUserCommand string emailVerificationToken) { ValidateOpenRegistrationAllowed(); + await ValidateEmailDomainNotBlockedAsync(user.Email); + var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email); user.EmailVerified = true; @@ -284,6 +302,7 @@ public class RegisterUserCommand : IRegisterUserCommand string orgSponsoredFreeFamilyPlanInviteToken) { ValidateOpenRegistrationAllowed(); + await ValidateEmailDomainNotBlockedAsync(user.Email); await ValidateOrgSponsoredFreeFamilyPlanInviteToken(orgSponsoredFreeFamilyPlanInviteToken, user.Email); user.EmailVerified = true; @@ -304,6 +323,7 @@ public class RegisterUserCommand : IRegisterUserCommand string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { ValidateOpenRegistrationAllowed(); + await ValidateEmailDomainNotBlockedAsync(user.Email); ValidateAcceptEmergencyAccessInviteToken(acceptEmergencyAccessInviteToken, acceptEmergencyAccessId, user.Email); user.EmailVerified = true; @@ -322,6 +342,7 @@ public class RegisterUserCommand : IRegisterUserCommand string providerInviteToken, Guid providerUserId) { ValidateOpenRegistrationAllowed(); + await ValidateEmailDomainNotBlockedAsync(user.Email); ValidateProviderInviteToken(providerInviteToken, providerUserId, user.Email); user.EmailVerified = true; @@ -387,6 +408,28 @@ public class RegisterUserCommand : IRegisterUserCommand return tokenable; } + private async Task ValidateEmailDomainNotBlockedAsync(string email, Guid? excludeOrganizationId = null) + { + // Only check if feature flag is enabled + if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)) + { + return; + } + + var emailDomain = EmailValidation.GetDomain(email); + + var isDomainBlocked = await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync( + emailDomain, excludeOrganizationId); + if (isDomainBlocked) + { + _logger.LogInformation( + "User registration blocked by domain claim policy. Domain: {Domain}, ExcludedOrgId: {ExcludedOrgId}", + emailDomain, + excludeOrganizationId); + throw new BadRequestException("This email address is claimed by an organization using Bitwarden."); + } + } + /// /// We send different welcome emails depending on whether the user is joining a free/family or an enterprise organization. If information to populate the /// email isn't present we send the standard individual welcome email. @@ -413,9 +456,7 @@ public class RegisterUserCommand : IRegisterUserCommand else if (!string.IsNullOrEmpty(organization.DisplayName())) { // If the organization is Free or Families plan, send families welcome email - if (organization.PlanType is PlanType.FamiliesAnnually - or PlanType.FamiliesAnnually2019 - or PlanType.Free) + if (organization.PlanType.GetProductTier() is ProductTierType.Free or ProductTierType.Families) { await _mailService.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.DisplayName()); } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs index 3f89e9ad0e..2e8587eee6 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs @@ -5,6 +5,8 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tokens; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; namespace Bit.Core.Auth.UserFeatures.Registration.Implementations; @@ -15,29 +17,34 @@ namespace Bit.Core.Auth.UserFeatures.Registration.Implementations; /// public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmailForRegistrationCommand { - + private readonly ILogger _logger; private readonly IUserRepository _userRepository; private readonly GlobalSettings _globalSettings; private readonly IMailService _mailService; private readonly IDataProtectorTokenFactory _tokenDataFactory; private readonly IFeatureService _featureService; + private readonly IOrganizationDomainRepository _organizationDomainRepository; public SendVerificationEmailForRegistrationCommand( + ILogger logger, IUserRepository userRepository, GlobalSettings globalSettings, IMailService mailService, IDataProtectorTokenFactory tokenDataFactory, - IFeatureService featureService) + IFeatureService featureService, + IOrganizationDomainRepository organizationDomainRepository) { + _logger = logger; _userRepository = userRepository; _globalSettings = globalSettings; _mailService = mailService; _tokenDataFactory = tokenDataFactory; _featureService = featureService; + _organizationDomainRepository = organizationDomainRepository; } - public async Task Run(string email, string? name, bool receiveMarketingEmails) + public async Task Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing) { if (_globalSettings.DisableUserRegistration) { @@ -49,6 +56,20 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai throw new ArgumentNullException(nameof(email)); } + // Check if the email domain is blocked by an organization policy + if (_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation)) + { + var emailDomain = EmailValidation.GetDomain(email); + + if (await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(emailDomain)) + { + _logger.LogInformation( + "User registration email verification blocked by domain claim policy. Domain: {Domain}", + emailDomain); + throw new BadRequestException("This email address is claimed by an organization using Bitwarden."); + } + } + // Check to see if the user already exists var user = await _userRepository.GetByEmailAsync(email); var userExists = user != null; @@ -71,7 +92,7 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai // If the user doesn't exist, create a new EmailVerificationTokenable and send the user // an email with a link to verify their email address var token = GenerateToken(email, name, receiveMarketingEmails); - await _mailService.SendRegistrationVerificationEmailAsync(email, token); + await _mailService.SendRegistrationVerificationEmailAsync(email, token, fromMarketing); } // User exists but we will return a 200 regardless of whether the email was sent or not; so return null diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 53bd8bdba2..7c50f7f17b 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ - - +using Bit.Core.Auth.Sso; using Bit.Core.Auth.UserFeatures.DeviceTrust; using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration.Implementations; @@ -29,6 +28,7 @@ public static class UserServiceCollectionExtensions services.AddWebAuthnLoginCommands(); services.AddTdeOffboardingPasswordCommands(); services.AddTwoFactorQueries(); + services.AddSsoQueries(); } public static void AddDeviceTrustCommands(this IServiceCollection services) @@ -69,4 +69,9 @@ public static class UserServiceCollectionExtensions { services.AddScoped(); } + + private static void AddSsoQueries(this IServiceCollection services) + { + services.AddScoped(); + } } diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 11f043fc69..dc128127ae 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -12,6 +12,12 @@ public static class StripeConstants public const string UnrecognizedLocation = "unrecognized_location"; } + public static class BillingReasons + { + public const string SubscriptionCreate = "subscription_create"; + public const string SubscriptionCycle = "subscription_cycle"; + } + public static class CollectionMethod { public const string ChargeAutomatically = "charge_automatically"; @@ -65,6 +71,7 @@ public static class StripeConstants public const string Region = "region"; public const string RetiredBraintreeCustomerId = "btCustomerId_old"; public const string UserId = "userId"; + public const string StorageReconciled2025 = "storage_reconciled_2025"; } public static class PaymentBehavior diff --git a/src/Core/Billing/Licenses/LicenseConstants.cs b/src/Core/Billing/Licenses/LicenseConstants.cs index 79ac94be62..727bcbc229 100644 --- a/src/Core/Billing/Licenses/LicenseConstants.cs +++ b/src/Core/Billing/Licenses/LicenseConstants.cs @@ -44,6 +44,7 @@ public static class OrganizationLicenseConstants public const string UseAdminSponsoredFamilies = nameof(UseAdminSponsoredFamilies); public const string UseOrganizationDomains = nameof(UseOrganizationDomains); public const string UseAutomaticUserConfirmation = nameof(UseAutomaticUserConfirmation); + public const string UsePhishingBlocker = nameof(UsePhishingBlocker); } public static class UserLicenseConstants diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs index e9aadbe758..4a4771857e 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -26,7 +26,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory All { get; set; } = + [ + new() + { + PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise, + SponsoredProductTierType = ProductTierType.Families, + SponsoringProductTierType = ProductTierType.Enterprise, + StripePlanId = "2021-family-for-enterprise-annually", + UsersCanSponsor = org => + org.PlanType.GetProductTier() == ProductTierType.Enterprise, + } + ]; + + public static SponsoredPlan Get(PlanSponsorshipType planSponsorshipType) => + All.FirstOrDefault(p => p.PlanSponsorshipType == planSponsorshipType)!; +} diff --git a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs index 89d301c22a..143da0d67f 100644 --- a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs +++ b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs @@ -3,12 +3,12 @@ using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Enums; using Bit.Core.Services; -using Bit.Core.Utilities; using Microsoft.Extensions.Logging; using OneOf; using Stripe; @@ -54,7 +54,7 @@ public class PreviewOrganizationTaxCommand( switch (purchase) { case { PasswordManager.Sponsored: true }: - var sponsoredPlan = StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise); + var sponsoredPlan = SponsoredPlans.Get(PlanSponsorshipType.FamiliesForEnterprise); items.Add(new InvoiceSubscriptionDetailsItemOptions { Price = sponsoredPlan.StripePlanId, diff --git a/src/Core/Billing/Organizations/Models/OrganizationLicense.cs b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs index 7ccbacc938..584021f22f 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationLicense.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs @@ -143,6 +143,7 @@ public class OrganizationLicense : ILicense public int? SmSeats { get; set; } public int? SmServiceAccounts { get; set; } public bool UseRiskInsights { get; set; } + public bool UsePhishingBlocker { get; set; } // Deprecated. Left for backwards compatibility with old license versions. public bool LimitCollectionCreationDeletion { get; set; } = true; @@ -228,7 +229,8 @@ public class OrganizationLicense : ILicense !p.Name.Equals(nameof(UseRiskInsights)) && !p.Name.Equals(nameof(UseAdminSponsoredFamilies)) && !p.Name.Equals(nameof(UseOrganizationDomains)) && - !p.Name.Equals(nameof(UseAutomaticUserConfirmation))) + !p.Name.Equals(nameof(UseAutomaticUserConfirmation)) && + !p.Name.Equals(nameof(UsePhishingBlocker))) .OrderBy(p => p.Name) .Select(p => $"{p.Name}:{Core.Utilities.CoreHelpers.FormatLicenseSignatureValue(p.GetValue(this, null))}") .Aggregate((c, n) => $"{c}|{n}"); @@ -399,7 +401,6 @@ public class OrganizationLicense : ILicense var installationId = claimsPrincipal.GetValue(nameof(InstallationId)); var licenseKey = claimsPrincipal.GetValue(nameof(LicenseKey)); var enabled = claimsPrincipal.GetValue(nameof(Enabled)); - var planType = claimsPrincipal.GetValue(nameof(PlanType)); var seats = claimsPrincipal.GetValue(nameof(Seats)); var maxCollections = claimsPrincipal.GetValue(nameof(MaxCollections)); var useGroups = claimsPrincipal.GetValue(nameof(UseGroups)); @@ -425,12 +426,18 @@ public class OrganizationLicense : ILicense var useOrganizationDomains = claimsPrincipal.GetValue(nameof(UseOrganizationDomains)); var useAutomaticUserConfirmation = claimsPrincipal.GetValue(nameof(UseAutomaticUserConfirmation)); + var claimedPlanType = claimsPrincipal.GetValue(nameof(PlanType)); + + var planTypesMatch = claimedPlanType == PlanType.FamiliesAnnually + ? organization.PlanType is PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2025 + : organization.PlanType == claimedPlanType; + return issued <= DateTime.UtcNow && expires >= DateTime.UtcNow && installationId == globalSettings.Installation.Id && licenseKey == organization.LicenseKey && enabled == organization.Enabled && - planType == organization.PlanType && + planTypesMatch && seats == organization.Seats && maxCollections == organization.MaxCollections && useGroups == organization.UseGroups && diff --git a/src/Core/Billing/Organizations/Models/SponsorOrganizationSubscriptionUpdate.cs b/src/Core/Billing/Organizations/Models/SponsorOrganizationSubscriptionUpdate.cs index ee603c67e0..6c1362d1c5 100644 --- a/src/Core/Billing/Organizations/Models/SponsorOrganizationSubscriptionUpdate.cs +++ b/src/Core/Billing/Organizations/Models/SponsorOrganizationSubscriptionUpdate.cs @@ -1,6 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.Billing.Models; using Bit.Core.Models.Business; using Stripe; @@ -17,7 +18,7 @@ public class SponsorOrganizationSubscriptionUpdate : SubscriptionUpdate { _existingPlanStripeId = existingPlan.PasswordManager.StripePlanId; _sponsoredPlanStripeId = sponsoredPlan?.StripePlanId - ?? Core.Utilities.StaticStore.SponsoredPlans.FirstOrDefault()?.StripePlanId; + ?? SponsoredPlans.All.FirstOrDefault()?.StripePlanId; _applySponsorship = applySponsorship; } diff --git a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs index d34bd86e7b..6c7f087ffa 100644 --- a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs @@ -56,4 +56,15 @@ public interface IOrganizationBillingService /// Thrown when the is . /// Thrown when no payment method is found for the customer, no plan IDs are provided, or subscription update fails. Task UpdateSubscriptionPlanFrequency(Organization organization, PlanType newPlanType); + + /// + /// Updates the organization name and email on the Stripe customer entry. + /// This only updates Stripe, not the Bitwarden database. + /// + /// + /// The caller should ensure that the organization has a GatewayCustomerId before calling this method. + /// + /// The organization to update in Stripe. + /// Thrown when the organization does not have a GatewayCustomerId. + Task UpdateOrganizationNameAndEmail(Organization organization); } diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index b10f04d766..65c339fad4 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -176,6 +176,35 @@ public class OrganizationBillingService( } } + public async Task UpdateOrganizationNameAndEmail(Organization organization) + { + if (organization.GatewayCustomerId is null) + { + throw new BillingException("Cannot update an organization in Stripe without a GatewayCustomerId."); + } + + var newDisplayName = organization.DisplayName(); + + await stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, + new CustomerUpdateOptions + { + Email = organization.BillingEmail, + Description = newDisplayName, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + // This overwrites the existing custom fields for this organization + CustomFields = [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = organization.SubscriberType(), + Value = newDisplayName.Length <= 30 + ? newDisplayName + : newDisplayName[..30] + }] + }, + }); + } + #region Utilities private async Task CreateCustomerAsync( diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs index 6fdef73885..ecb85ed7e8 100644 --- a/src/Core/Billing/Pricing/PricingClient.cs +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -6,7 +6,6 @@ using Bit.Core.Billing.Pricing.Organizations; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Utilities; using Microsoft.Extensions.Logging; namespace Bit.Core.Billing.Pricing; @@ -28,13 +27,6 @@ public class PricingClient( return null; } - var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService); - - if (!usePricingService) - { - return StaticStore.GetPlan(planType); - } - var lookupKey = GetLookupKey(planType); if (lookupKey == null) @@ -77,13 +69,6 @@ public class PricingClient( return []; } - var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService); - - if (!usePricingService) - { - return StaticStore.Plans.ToList(); - } - var response = await httpClient.GetAsync("plans/organization"); if (response.IsSuccessStatusCode) @@ -114,11 +99,10 @@ public class PricingClient( return []; } - var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService); var fetchPremiumPriceFromPricingService = featureService.IsEnabled(FeatureFlagKeys.PM26793_FetchPremiumPriceFromPricingService); - if (!usePricingService || !fetchPremiumPriceFromPricingService) + if (!fetchPremiumPriceFromPricingService) { return [CurrentPremiumPlan]; } diff --git a/src/Core/Billing/Services/ISubscriberService.cs b/src/Core/Billing/Services/ISubscriberService.cs index f88727f37b..343a0e4f38 100644 --- a/src/Core/Billing/Services/ISubscriberService.cs +++ b/src/Core/Billing/Services/ISubscriberService.cs @@ -6,7 +6,6 @@ using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Stripe; -using PaymentMethod = Bit.Core.Billing.Models.PaymentMethod; namespace Bit.Core.Billing.Services; @@ -64,16 +63,6 @@ public interface ISubscriberService ISubscriber subscriber, CustomerGetOptions customerGetOptions = null); - /// - /// Retrieves the account credit, a masked representation of the default payment source and the tax information for the - /// provided . This is essentially a consolidated invocation of the - /// and methods with a response that includes the customer's as account credit in order to cut down on Stripe API calls. - /// - /// The subscriber to retrieve payment method for. - /// A containing the subscriber's account credit, payment source and tax information. - Task GetPaymentMethod( - ISubscriber subscriber); - /// /// Retrieves a masked representation of the subscriber's payment source for presentation to a client. /// @@ -107,16 +96,6 @@ public interface ISubscriberService ISubscriber subscriber, SubscriptionGetOptions subscriptionGetOptions = null); - /// - /// Retrieves the 's tax information using their Stripe 's . - /// - /// The subscriber to retrieve the tax information for. - /// A representing the 's tax information. - /// Thrown when the is . - /// This method opts for returning rather than throwing exceptions, making it ideal for surfacing data from API endpoints. - Task GetTaxInformation( - ISubscriber subscriber); - /// /// Attempts to remove a subscriber's saved payment source. If the Stripe representing the /// contains a valid "btCustomerId" key in its property, @@ -147,17 +126,6 @@ public interface ISubscriberService ISubscriber subscriber, TaxInformation taxInformation); - /// - /// Verifies the subscriber's pending bank account using the provided . - /// - /// The subscriber to verify the bank account for. - /// The code attached to a deposit made to the subscriber's bank account in order to ensure they have access to it. - /// Learn more. - /// - Task VerifyBankAccount( - ISubscriber subscriber, - string descriptorCode); - /// /// Validates whether the 's exists in the gateway. /// If the 's is or empty, returns . diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 8e75bf3dca..4b2ea26294 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -24,7 +24,6 @@ using Stripe; using static Bit.Core.Billing.Utilities; using Customer = Stripe.Customer; -using PaymentMethod = Bit.Core.Billing.Models.PaymentMethod; using Subscription = Stripe.Subscription; namespace Bit.Core.Billing.Services.Implementations; @@ -330,38 +329,6 @@ public class SubscriberService( } } - public async Task GetPaymentMethod( - ISubscriber subscriber) - { - ArgumentNullException.ThrowIfNull(subscriber); - - var customer = await GetCustomer(subscriber, new CustomerGetOptions - { - Expand = ["default_source", "invoice_settings.default_payment_method", "subscriptions", "tax_ids"] - }); - - if (customer == null) - { - return PaymentMethod.Empty; - } - - var accountCredit = customer.Balance * -1 / 100M; - - var paymentMethod = await GetPaymentSourceAsync(subscriber.Id, customer); - - var subscriptionStatus = customer.Subscriptions - .FirstOrDefault(subscription => subscription.Id == subscriber.GatewaySubscriptionId)? - .Status; - - var taxInformation = GetTaxInformation(customer); - - return new PaymentMethod( - accountCredit, - paymentMethod, - subscriptionStatus, - taxInformation); - } - public async Task GetPaymentSource( ISubscriber subscriber) { @@ -449,16 +416,6 @@ public class SubscriberService( } } - public async Task GetTaxInformation( - ISubscriber subscriber) - { - ArgumentNullException.ThrowIfNull(subscriber); - - var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions { Expand = ["tax_ids"] }); - - return GetTaxInformation(customer); - } - public async Task RemovePaymentSource( ISubscriber subscriber) { @@ -823,57 +780,6 @@ public class SubscriberService( } } - public async Task VerifyBankAccount( - ISubscriber subscriber, - string descriptorCode) - { - var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id); - - if (string.IsNullOrEmpty(setupIntentId)) - { - logger.LogError("No setup intent ID exists to verify for subscriber with ID ({SubscriberID})", subscriber.Id); - throw new BillingException(); - } - - try - { - await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId, - new SetupIntentVerifyMicrodepositsOptions { DescriptorCode = descriptorCode }); - - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId); - - await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId, - new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); - - await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, - new CustomerUpdateOptions - { - InvoiceSettings = new CustomerInvoiceSettingsOptions - { - DefaultPaymentMethod = setupIntent.PaymentMethodId - } - }); - } - catch (StripeException stripeException) - { - if (!string.IsNullOrEmpty(stripeException.StripeError?.Code)) - { - var message = stripeException.StripeError.Code switch - { - StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationAttemptsExceeded => "You have exceeded the number of allowed verification attempts. Please contact support.", - StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationDescriptorCodeMismatch => "The verification code you provided does not match the one sent to your bank account. Please try again.", - StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationTimeout => "Your bank account was not verified within the required time period. Please contact support.", - _ => BillingException.DefaultMessage - }; - - throw new BadRequestException(message); - } - - logger.LogError(stripeException, "An unhandled Stripe exception was thrown while verifying subscriber's ({SubscriberID}) bank account", subscriber.Id); - throw new BillingException(); - } - } - public async Task IsValidGatewayCustomerIdAsync(ISubscriber subscriber) { ArgumentNullException.ThrowIfNull(subscriber); @@ -970,25 +876,6 @@ public class SubscriberService( return PaymentSource.From(setupIntent); } - private static TaxInformation GetTaxInformation( - Customer customer) - { - if (customer.Address == null) - { - return null; - } - - return new TaxInformation( - customer.Address.Country, - customer.Address.PostalCode, - customer.TaxIds?.FirstOrDefault()?.Value, - customer.TaxIds?.FirstOrDefault()?.Type, - customer.Address.Line1, - customer.Address.Line2, - customer.Address.City, - customer.Address.State); - } - private async Task RemoveBraintreeCustomerIdAsync( Customer customer) { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3d0e7d71c9..3710cb4a23 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -140,8 +140,9 @@ public static class FeatureFlagKeys public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; - public const string AccountRecoveryCommand = "pm-25581-prevent-provider-account-recovery"; - public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects"; + public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration"; + public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud"; + public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2"; /* Architecture */ public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; @@ -155,15 +156,15 @@ public static class FeatureFlagKeys public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public const string Otp6Digits = "pm-18612-otp-6-digits"; - public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email"; public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users"; public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword = "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; public const string RecoveryCodeSupportForSsoRequiredUsers = "pm-21153-recovery-code-support-for-sso-required"; public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; - public const string MjmlWelcomeEmailTemplates = "mjml-welcome-email-templates"; + public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email"; public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow"; + public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; @@ -185,9 +186,6 @@ public static class FeatureFlagKeys /* Billing Team */ public const string TrialPayment = "PM-8163-trial-payment"; - public const string UsePricingService = "use-pricing-service"; - public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; - public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; 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"; @@ -197,17 +195,16 @@ public static class FeatureFlagKeys public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service"; public const string PM23341_Milestone_2 = "pm-23341-milestone-2"; public const string PM26462_Milestone_3 = "pm-26462-milestone-3"; + public const string PM28265_EnableReconcileAdditionalStorageJob = "pm-28265-enable-reconcile-additional-storage-job"; + public const string PM28265_ReconcileAdditionalStorageJobEnableLiveMode = "pm-28265-reconcile-additional-storage-job-enable-live-mode"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; - public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service"; public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration"; public const string Argon2Default = "argon2-default"; public const string UserkeyRotationV2 = "userkey-rotation-v2"; public const string SSHKeyItemVaultItem = "ssh-key-vault-item"; - public const string UserSdkForDecryption = "use-sdk-for-decryption"; public const string EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation"; - public const string PM17987_BlockType0 = "pm-17987-block-type-0"; public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings"; public const string UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data"; public const string WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2"; @@ -215,6 +212,7 @@ public static class FeatureFlagKeys public const string NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change"; public const string DisableType0Decryption = "pm-25174-disable-type-0-decryption"; public const string ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component"; + public const string DataRecoveryTool = "pm-28813-data-recovery-tool"; /* Mobile Team */ public const string AndroidImportLoginsFlow = "import-logins-flow"; @@ -240,21 +238,20 @@ public static class FeatureFlagKeys public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators"; public const string UseChromiumImporter = "pm-23982-chromium-importer"; public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe"; + public const string SendUIRefresh = "pm-28175-send-ui-refresh"; /* Vault Team */ - public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; - public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; public const string CipherKeyEncryption = "cipher-key-encryption"; public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk"; - public const string EndUserNotifications = "pm-10609-end-user-notifications"; public const string PhishingDetection = "phishing-detection"; public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy"; public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view"; - public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp"; public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption"; public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium"; public const string PM25083_AutofillConfirmFromSearch = "pm-25083-autofill-confirm-from-search"; public const string VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders"; + public const string BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight"; + public const string MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems"; /* Innovation Team */ public const string ArchiveVaultItems = "pm-19148-innovation-archive"; @@ -264,6 +261,9 @@ public static class FeatureFlagKeys public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging"; + /* UIF Team */ + public const string RouterFocusManagement = "router-focus-management"; + public static List GetAllKeys() { return typeof(FeatureFlagKeys).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index 5d9b5a1759..6067c60556 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -38,10 +38,6 @@ public class CurrentContext( public virtual List Providers { get; set; } public virtual Guid? InstallationId { get; set; } public virtual Guid? OrganizationId { get; set; } - public virtual bool CloudflareWorkerProxied { get; set; } - public virtual bool IsBot { get; set; } - public virtual bool MaybeBot { get; set; } - public virtual int? BotScore { get; set; } public virtual string ClientId { get; set; } public virtual Version ClientVersion { get; set; } public virtual bool ClientVersionIsPrerelease { get; set; } @@ -70,27 +66,6 @@ public class CurrentContext( DeviceType = dType; } - if (!BotScore.HasValue && httpContext.Request.Headers.TryGetValue("X-Cf-Bot-Score", out var cfBotScore) && - int.TryParse(cfBotScore, out var parsedBotScore)) - { - BotScore = parsedBotScore; - } - - if (httpContext.Request.Headers.TryGetValue("X-Cf-Worked-Proxied", out var cfWorkedProxied)) - { - CloudflareWorkerProxied = cfWorkedProxied == "1"; - } - - if (httpContext.Request.Headers.TryGetValue("X-Cf-Is-Bot", out var cfIsBot)) - { - IsBot = cfIsBot == "1"; - } - - if (httpContext.Request.Headers.TryGetValue("X-Cf-Maybe-Bot", out var cfMaybeBot)) - { - MaybeBot = cfMaybeBot == "1"; - } - if (httpContext.Request.Headers.TryGetValue("Bitwarden-Client-Version", out var bitWardenClientVersion) && Version.TryParse(bitWardenClientVersion, out var cVersion)) { ClientVersion = cVersion; diff --git a/src/Core/Context/ICurrentContext.cs b/src/Core/Context/ICurrentContext.cs index f62a048070..d527cdd363 100644 --- a/src/Core/Context/ICurrentContext.cs +++ b/src/Core/Context/ICurrentContext.cs @@ -31,9 +31,6 @@ public interface ICurrentContext Guid? InstallationId { get; set; } Guid? OrganizationId { get; set; } IdentityClientType IdentityClientType { get; set; } - bool IsBot { get; set; } - bool MaybeBot { get; set; } - int? BotScore { get; set; } string ClientId { get; set; } Version ClientVersion { get; set; } bool ClientVersionIsPrerelease { get; set; } diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 81370fe173..4902d5bdbe 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -23,14 +23,14 @@ - - - + + + - - - + + + @@ -50,23 +50,19 @@ - - - - - + - - - - + + + + diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index fec9b80d8e..1ca6606779 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -200,11 +200,6 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac return Id; } - public bool GetPremium() - { - return Premium; - } - public int GetSecurityVersion() { // If no security version is set, it is version 1. The minimum initialized version is 2. diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index 0e551c5d0e..abaf9406ba 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -26,5 +26,6 @@ public static class KeyManagementServiceCollectionExtensions private static void AddKeyManagementQueries(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs b/src/Core/KeyManagement/Models/Api/Request/AccountKeysRequestModel.cs similarity index 92% rename from src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs rename to src/Core/KeyManagement/Models/Api/Request/AccountKeysRequestModel.cs index b64e826911..bdf538e6d8 100644 --- a/src/Api/KeyManagement/Models/Requests/AccountKeysRequestModel.cs +++ b/src/Core/KeyManagement/Models/Api/Request/AccountKeysRequestModel.cs @@ -1,8 +1,7 @@ -using Bit.Core.KeyManagement.Models.Api.Request; -using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; -namespace Bit.Api.KeyManagement.Models.Requests; +namespace Bit.Core.KeyManagement.Models.Api.Request; public class AccountKeysRequestModel { diff --git a/src/Api/KeyManagement/Models/Requests/PublicKeyEncryptionKeyPairRequestModel.cs b/src/Core/KeyManagement/Models/Api/Request/PublicKeyEncryptionKeyPairRequestModel.cs similarity index 91% rename from src/Api/KeyManagement/Models/Requests/PublicKeyEncryptionKeyPairRequestModel.cs rename to src/Core/KeyManagement/Models/Api/Request/PublicKeyEncryptionKeyPairRequestModel.cs index 24c1e6a946..f9b009f7e2 100644 --- a/src/Api/KeyManagement/Models/Requests/PublicKeyEncryptionKeyPairRequestModel.cs +++ b/src/Core/KeyManagement/Models/Api/Request/PublicKeyEncryptionKeyPairRequestModel.cs @@ -1,7 +1,7 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; -namespace Bit.Api.KeyManagement.Models.Requests; +namespace Bit.Core.KeyManagement.Models.Api.Request; public class PublicKeyEncryptionKeyPairRequestModel { diff --git a/src/Api/KeyManagement/Models/Requests/SignatureKeyPairRequestModel.cs b/src/Core/KeyManagement/Models/Api/Request/SignatureKeyPairRequestModel.cs similarity index 93% rename from src/Api/KeyManagement/Models/Requests/SignatureKeyPairRequestModel.cs rename to src/Core/KeyManagement/Models/Api/Request/SignatureKeyPairRequestModel.cs index 3cdb4f53f1..a569bc70ab 100644 --- a/src/Api/KeyManagement/Models/Requests/SignatureKeyPairRequestModel.cs +++ b/src/Core/KeyManagement/Models/Api/Request/SignatureKeyPairRequestModel.cs @@ -1,7 +1,7 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; -namespace Bit.Api.KeyManagement.Models.Requests; +namespace Bit.Core.KeyManagement.Models.Api.Request; public class SignatureKeyPairRequestModel { diff --git a/src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs b/src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs new file mode 100644 index 0000000000..3821831bad --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/KeyConnectorConfirmationDetails.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.KeyManagement.Models.Data; + +public class KeyConnectorConfirmationDetails +{ + public required string OrganizationName { get; set; } +} diff --git a/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs index cabdca59ea..3d552a10de 100644 --- a/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs +++ b/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs @@ -1,9 +1,34 @@ namespace Bit.Core.KeyManagement.Models.Data; - +/// +/// Represents an expanded account cryptographic state for a user. Expanded here means +/// that it does not only contain the (wrapped) private / signing key, but also the public +/// key / verifying key. The client side only needs a subset of this data to unlock +/// their vault and the public parts can be derived. +/// public class UserAccountKeysData { public required PublicKeyEncryptionKeyPairData PublicKeyEncryptionKeyPairData { get; set; } public SignatureKeyPairData? SignatureKeyPairData { get; set; } public SecurityStateData? SecurityStateData { get; set; } + + /// + /// Checks whether the account cryptographic state is for a V1 encryption user or a V2 encryption user. + /// Throws if the state is invalid + /// + public bool IsV2Encryption() + { + if (PublicKeyEncryptionKeyPairData.SignedPublicKey != null && SignatureKeyPairData != null && SecurityStateData != null) + { + return true; + } + else if (PublicKeyEncryptionKeyPairData.SignedPublicKey == null && SignatureKeyPairData == null && SecurityStateData == null) + { + return false; + } + else + { + throw new InvalidOperationException("Invalid account cryptographic state: V2 encryption fields must be either all present or all absent."); + } + } } diff --git a/src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs b/src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs new file mode 100644 index 0000000000..60b78c03f4 --- /dev/null +++ b/src/Core/KeyManagement/Queries/Interfaces/IKeyConnectorConfirmationDetailsQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.Queries.Interfaces; + +public interface IKeyConnectorConfirmationDetailsQuery +{ + public Task Run(string orgSsoIdentifier, Guid userId); +} diff --git a/src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs b/src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs new file mode 100644 index 0000000000..0c210e2fd1 --- /dev/null +++ b/src/Core/KeyManagement/Queries/KeyConnectorConfirmationDetailsQuery.cs @@ -0,0 +1,35 @@ +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Queries.Interfaces; +using Bit.Core.Repositories; + +namespace Bit.Core.KeyManagement.Queries; + +public class KeyConnectorConfirmationDetailsQuery : IKeyConnectorConfirmationDetailsQuery +{ + private readonly IOrganizationRepository _organizationRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + + public KeyConnectorConfirmationDetailsQuery(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository) + { + _organizationRepository = organizationRepository; + _organizationUserRepository = organizationUserRepository; + } + + public async Task Run(string orgSsoIdentifier, Guid userId) + { + var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier); + if (org is not { UseKeyConnector: true }) + { + throw new NotFoundException(); + } + + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, userId); + if (orgUser == null) + { + throw new NotFoundException(); + } + + return new KeyConnectorConfirmationDetails { OrganizationName = org.Name, }; + } +} diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs new file mode 100644 index 0000000000..65e37e87dd --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs @@ -0,0 +1,815 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    + + + + + + + +
    + + + + + + + + +
    + + + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + + + + + + + + + +
    + + + + + + + +
    + + + +
    + +
    + +

    + You can now share passwords with members of {{OrganizationName}}! +

    + +
    + + + + + + + +
    + + Log in + +
    + +
    + +
    + + + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + +
    + +
    + + +
    + +
    + + + + + +
    + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + +
    + +
    As a member of {{OrganizationName}}:
    + +
    + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + + +
    + + +
    + + + + + + + + + +
    + + + + + + + +
    + + Organization Icon + +
    + +
    + +
    + + + +
    + + + + + + + + + +
    + +
    Your account is owned by {{OrganizationName}} and is subject to their security and management policies.
    + +
    + +
    + + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + + +
    + + +
    + + + + + + + + + +
    + + + + + + + +
    + + Group Users Icon + +
    + +
    + +
    + + + +
    + + + + + + + + + + + + + +
    + +
    You can easily access and share passwords with your team.
    + +
    + + + +
    + +
    + + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + +
    + +

    + Learn more about Bitwarden +

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

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

    +

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

    + +
    + +
    + + +
    + +
    + + + + + +
    + + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.text.hbs b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.text.hbs new file mode 100644 index 0000000000..38c45f2dd1 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.text.hbs @@ -0,0 +1,4 @@ +{{#>TitleContactUsTextLayout}} + You may now access logins and other items {{OrganizationName}} has shared with you from your Bitwarden vault. + Tip: Use the Bitwarden mobile app to quickly save logins and auto-fill forms. Download from the App Store or Google Play. +{{/TitleContactUsTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs new file mode 100644 index 0000000000..c22bc80a51 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs @@ -0,0 +1,983 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    + + + + + + + +
    + + + + + + + + +
    + + + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + + + + + + + + + +
    + + + + + + + +
    + + + +
    + +
    + +

    + You can now share passwords with members of {{OrganizationName}}! +

    + +
    + + + + + + + +
    + + Log in + +
    + +
    + +
    + + + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + +
    + +
    + + +
    + +
    + + + + + +
    + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + +
    + +
    As a member of {{OrganizationName}}:
    + +
    + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + + +
    + + +
    + + + + + + + + + +
    + + + + + + + +
    + + Collections Icon + +
    + +
    + +
    + + + +
    + + + + + + + + + +
    + +
    You can access passwords {{OrganizationName}} has shared with you.
    + +
    + +
    + + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + + +
    + + +
    + + + + + + + + + +
    + + + + + + + +
    + + Group Users Icon + +
    + +
    + +
    + + + +
    + + + + + + + + + + + + + +
    + +
    You can easily share passwords with friends, family, or coworkers.
    + +
    + + + +
    + +
    + + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + +
    + +
    + + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + + + + + +
    + +
    Download Bitwarden on all devices
    + +
    + +
    Already using the browser extension? + Download the Bitwarden mobile app from the + App Store + or Google Play + to quickly save logins and autofill forms on the go.
    + +
    + +
    + + +
    + +
    + + + + + +
    + + + + + + + +
    + + +
    + + +
    + + + + + + + + + +
    + + + + + + + +
    + + + + Download on the App Store + + + +
    + +
    + +
    + + + +
    + + + + + + + + + +
    + + + + + + + +
    + + + + Get it on Google Play + + + +
    + +
    + +
    + + +
    + + +
    + +
    + + + +
    + +
    + + + + + + + + + +
    + + + + + + + +
    + + + +
    + + + + + + + +
    + + +
    + + + + + + + + + +
    + +

    + Learn more about Bitwarden +

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

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

    +

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

    + +
    + +
    + + +
    + +
    + + + + + +
    + + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.text.hbs b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.text.hbs new file mode 100644 index 0000000000..38c45f2dd1 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.text.hbs @@ -0,0 +1,4 @@ +{{#>TitleContactUsTextLayout}} + You may now access logins and other items {{OrganizationName}} has shared with you from your Bitwarden vault. + Tip: Use the Bitwarden mobile app to quickly save logins and auto-fill forms. Download from the App Store or Google Play. +{{/TitleContactUsTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs index 3cbc9446c8..9c4b2406d4 100644 --- a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs @@ -53,11 +53,37 @@ - - + @@ -156,7 +161,7 @@

    - Let's get set up to autofill. + Let’s get you set up to autofill.

    @@ -176,7 +181,7 @@ - + @@ -256,7 +261,7 @@ @@ -643,7 +648,7 @@ -
    -
    A {{OrganizationName}} administrator will approve you +
    An administrator from {{OrganizationName}} will approve you before you can share passwords. While you wait for approval, get started with Bitwarden Password Manager:
    @@ -622,10 +627,10 @@

    - Learn more about Bitwarden -

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

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