From f7ff5b28142b80b44e4053ec8c5ee14453ab3ff5 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Wed, 25 Feb 2026 08:44:00 -0600 Subject: [PATCH 01/85] PM-31923 adding the whole report endpoints v2 --- .../OrganizationReportsV2Controller.cs | 170 ++++++++++++++++ .../OrganizationReportResponseModel.cs | 2 + .../OrganizationReportV2ResponseModel.cs | 11 ++ src/Core/Dirt/Entities/OrganizationReport.cs | 3 +- ...ganizationReportDataFileStorageResponse.cs | 6 + .../Data/OrganizationReportMetricsData.cs | 2 +- .../AddOrganizationReportCommand.cs | 2 +- .../CreateOrganizationReportV2Command.cs | 93 +++++++++ ...tOrganizationReportApplicationDataQuery.cs | 39 +--- .../GetOrganizationReportDataV2Query.cs | 50 +++++ .../ICreateOrganizationReportV2Command.cs | 9 + .../IGetOrganizationReportDataV2Query.cs | 8 + .../IUpdateOrganizationReportDataV2Command.cs | 8 + .../ReportingServiceCollectionExtensions.cs | 7 + .../Requests/AddOrganizationReportRequest.cs | 2 +- .../Requests/OrganizationReportMetrics.cs | 31 +++ .../UpdateOrganizationReportDataRequest.cs | 7 +- .../UpdateOrganizationReportSummaryRequest.cs | 2 +- .../UpdateOrganizationReportDataCommand.cs | 2 +- .../UpdateOrganizationReportDataV2Command.cs | 49 +++++ .../UpdateOrganizationReportSummaryCommand.cs | 2 +- .../AzureOrganizationReportStorageService.cs | 63 ++++++ .../IOrganizationReportStorageService.cs | 16 ++ .../LocalOrganizationReportStorageService.cs | 67 +++++++ .../NoopOrganizationReportStorageService.cs | 17 ++ src/Core/Settings/GlobalSettings.cs | 2 + .../Utilities/ServiceCollectionExtensions.cs | 14 ++ .../AutoFixture/GlobalSettingsFixtures.cs | 1 + .../CreateOrganizationReportV2CommandTests.cs | 137 +++++++++++++ .../GetOrganizationReportDataV2QueryTests.cs | 114 +++++++++++ ...ateOrganizationReportDataV2CommandTests.cs | 83 ++++++++ ...reOrganizationReportStorageServiceTests.cs | 113 +++++++++++ ...alOrganizationReportStorageServiceTests.cs | 184 ++++++++++++++++++ 33 files changed, 1266 insertions(+), 50 deletions(-) create mode 100644 src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs create mode 100644 src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs create mode 100644 src/Core/Dirt/Models/Data/OrganizationReportDataFileStorageResponse.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportV2Command.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataV2Query.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataV2Command.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/OrganizationReportMetrics.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs create mode 100644 src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs create mode 100644 src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs create mode 100644 src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs create mode 100644 src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs create mode 100644 test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs create mode 100644 test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs diff --git a/src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs b/src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs new file mode 100644 index 000000000000..fca68b3c1d3e --- /dev/null +++ b/src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs @@ -0,0 +1,170 @@ +using Bit.Api.Dirt.Models.Response; +using Bit.Api.Utilities; +using Bit.Core; +using Bit.Core.Context; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Dirt.Controllers; + +[Route("reports/v2/organizations")] +[Authorize("Application")] +public class OrganizationReportsV2Controller : Controller +{ + private readonly ICurrentContext _currentContext; + private readonly IApplicationCacheService _applicationCacheService; + private readonly IOrganizationReportStorageService _storageService; + private readonly ICreateOrganizationReportV2Command _createCommand; + private readonly IUpdateOrganizationReportDataV2Command _updateDataCommand; + private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; + private readonly IGetOrganizationReportDataV2Query _getDataQuery; + private readonly IUpdateOrganizationReportCommand _updateOrganizationReportCommand; + + public OrganizationReportsV2Controller( + ICurrentContext currentContext, + IApplicationCacheService applicationCacheService, + IOrganizationReportStorageService storageService, + ICreateOrganizationReportV2Command createCommand, + IUpdateOrganizationReportDataV2Command updateDataCommand, + IGetOrganizationReportQuery getOrganizationReportQuery, + IGetOrganizationReportDataV2Query getDataQuery, + IUpdateOrganizationReportCommand updateOrganizationReportCommand) + { + _currentContext = currentContext; + _applicationCacheService = applicationCacheService; + _storageService = storageService; + _createCommand = createCommand; + _updateDataCommand = updateDataCommand; + _getOrganizationReportQuery = getOrganizationReportQuery; + _getDataQuery = getDataQuery; + _updateOrganizationReportCommand = updateOrganizationReportCommand; + } + + private async Task AuthorizeAsync(Guid organizationId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + if (orgAbility is null || !orgAbility.UseRiskInsights) + { + throw new BadRequestException("Your organization's plan does not support this feature."); + } + } + + [HttpPost("{organizationId}")] + public async Task CreateOrganizationReportAsync( + Guid organizationId, + [FromBody] AddOrganizationReportRequest request) + { + if (organizationId == Guid.Empty) + { + throw new BadRequestException("Organization ID is required."); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + await AuthorizeAsync(organizationId); + + var report = await _createCommand.CreateAsync(request); + + return new OrganizationReportV2ResponseModel + { + ReportDataUploadUrl = await _storageService.GetReportDataUploadUrlAsync(report, report.FileId!), + ReportResponse = new OrganizationReportResponseModel(report) + }; + } + + [HttpGet("{organizationId}/{reportId}")] + public async Task GetOrganizationReportAsync( + Guid organizationId, + Guid reportId) + { + await AuthorizeAsync(organizationId); + + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + + if (report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } + + return new OrganizationReportResponseModel(report); + } + + [HttpPatch("{organizationId}/data/report/{reportId}")] + public async Task GetReportDataUploadUrlAsync( + Guid organizationId, + Guid reportId, + [FromBody] UpdateOrganizationReportDataRequest request, + [FromQuery] string reportFileId) + { + if (request.OrganizationId != organizationId || request.ReportId != reportId) + { + throw new BadRequestException("Organization ID and Report ID must match route parameters"); + } + + if (string.IsNullOrEmpty(reportFileId)) + { + throw new BadRequestException("ReportFileId query parameter is required"); + } + + await AuthorizeAsync(organizationId); + + var uploadUrl = await _updateDataCommand.GetUploadUrlAsync(request, reportFileId); + + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + + return new OrganizationReportV2ResponseModel + { + ReportDataUploadUrl = uploadUrl, + ReportResponse = new OrganizationReportResponseModel(report) + }; + } + + [HttpPost("{organizationId}/{reportId}/file/report-data")] + [SelfHosted(SelfHostedOnly = true)] + [RequestSizeLimit(Constants.FileSize501mb)] + [DisableFormValueModelBinding] + public async Task UploadReportDataAsync(Guid organizationId, Guid reportId, [FromQuery] string reportFileId) + { + await AuthorizeAsync(organizationId); + + if (!Request?.ContentType?.Contains("multipart/") ?? true) + { + throw new BadRequestException("Invalid content."); + } + + if (string.IsNullOrEmpty(reportFileId)) + { + throw new BadRequestException("ReportFileId query parameter is required"); + } + + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + if (report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } + + if (report.FileId != reportFileId) + { + throw new NotFoundException(); + } + + await Request.GetFileAsync(async (stream) => + { + await _storageService.UploadReportDataAsync(report, reportFileId, stream); + }); + } +} diff --git a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs index e477e5b806a7..d40901934978 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs @@ -13,6 +13,7 @@ public class OrganizationReportResponseModel public int? PasswordCount { get; set; } public int? PasswordAtRiskCount { get; set; } public int? MemberCount { get; set; } + public string? FileId { get; set; } public DateTime? CreationDate { get; set; } = null; public DateTime? RevisionDate { get; set; } = null; @@ -32,6 +33,7 @@ public OrganizationReportResponseModel(OrganizationReport organizationReport) PasswordCount = organizationReport.PasswordCount; PasswordAtRiskCount = organizationReport.PasswordAtRiskCount; MemberCount = organizationReport.MemberCount; + FileId = organizationReport.FileId; CreationDate = organizationReport.CreationDate; RevisionDate = organizationReport.RevisionDate; } diff --git a/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs new file mode 100644 index 000000000000..3f5e40a76cde --- /dev/null +++ b/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs @@ -0,0 +1,11 @@ +using Bit.Core.Models.Api; + +namespace Bit.Api.Dirt.Models.Response; + +public class OrganizationReportV2ResponseModel : ResponseModel +{ + public OrganizationReportV2ResponseModel() : base("organizationReport-v2") { } + + public string ReportDataUploadUrl { get; set; } = string.Empty; + public OrganizationReportResponseModel ReportResponse { get; set; } = null!; +} diff --git a/src/Core/Dirt/Entities/OrganizationReport.cs b/src/Core/Dirt/Entities/OrganizationReport.cs index 9d04180c8d99..962618ddd5ff 100644 --- a/src/Core/Dirt/Entities/OrganizationReport.cs +++ b/src/Core/Dirt/Entities/OrganizationReport.cs @@ -27,8 +27,7 @@ public class OrganizationReport : ITableObject public int? PasswordAtRiskCount { get; set; } public int? CriticalPasswordCount { get; set; } public int? CriticalPasswordAtRiskCount { get; set; } - - + public string? FileId { get; set; } public void SetNewId() { diff --git a/src/Core/Dirt/Models/Data/OrganizationReportDataFileStorageResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportDataFileStorageResponse.cs new file mode 100644 index 000000000000..8af6799810e0 --- /dev/null +++ b/src/Core/Dirt/Models/Data/OrganizationReportDataFileStorageResponse.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Dirt.Models.Data; + +public class OrganizationReportDataFileStorageResponse +{ + public string DownloadUrl { get; set; } = string.Empty; +} diff --git a/src/Core/Dirt/Models/Data/OrganizationReportMetricsData.cs b/src/Core/Dirt/Models/Data/OrganizationReportMetricsData.cs index ffef91275a64..957dca5d641e 100644 --- a/src/Core/Dirt/Models/Data/OrganizationReportMetricsData.cs +++ b/src/Core/Dirt/Models/Data/OrganizationReportMetricsData.cs @@ -18,7 +18,7 @@ public class OrganizationReportMetricsData public int? CriticalPasswordCount { get; set; } public int? CriticalPasswordAtRiskCount { get; set; } - public static OrganizationReportMetricsData From(Guid organizationId, OrganizationReportMetricsRequest? request) + public static OrganizationReportMetricsData From(Guid organizationId, OrganizationReportMetrics? request) { if (request == null) { diff --git a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs index 236560487e92..2c700dd7e7ff 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs @@ -35,7 +35,7 @@ public async Task AddOrganizationReportAsync(AddOrganization throw new BadRequestException(errorMessage); } - var requestMetrics = request.Metrics ?? new OrganizationReportMetricsRequest(); + var requestMetrics = request.ReportMetrics ?? new OrganizationReportMetrics(); var organizationReport = new OrganizationReport { diff --git a/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs new file mode 100644 index 000000000000..f106e4d15476 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs @@ -0,0 +1,93 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class CreateOrganizationReportV2Command : ICreateOrganizationReportV2Command +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public CreateOrganizationReportV2Command( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task CreateAsync(AddOrganizationReportRequest request) + { + _logger.LogInformation(Constants.BypassFiltersEventId, + "Creating organization report for organization {organizationId}", request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogInformation(Constants.BypassFiltersEventId, + "Failed to create organization {organizationId} report: {errorMessage}", + request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var reportFileId = CoreHelpers.SecureRandomString(32, upper: false, special: false); + + var organizationReport = new OrganizationReport + { + OrganizationId = request.OrganizationId, + ReportData = string.Empty, + CreationDate = DateTime.UtcNow, + ContentEncryptionKey = request.ContentEncryptionKey ?? string.Empty, + SummaryData = request.SummaryData, + ApplicationData = request.ApplicationData, + FileId = reportFileId, + ApplicationCount = request.ReportMetrics?.ApplicationCount, + ApplicationAtRiskCount = request.ReportMetrics?.ApplicationAtRiskCount, + CriticalApplicationCount = request.ReportMetrics?.CriticalApplicationCount, + CriticalApplicationAtRiskCount = request.ReportMetrics?.CriticalApplicationAtRiskCount, + MemberCount = request.ReportMetrics?.MemberCount, + MemberAtRiskCount = request.ReportMetrics?.MemberAtRiskCount, + CriticalMemberCount = request.ReportMetrics?.CriticalMemberCount, + CriticalMemberAtRiskCount = request.ReportMetrics?.CriticalMemberAtRiskCount, + PasswordCount = request.ReportMetrics?.PasswordCount, + PasswordAtRiskCount = request.ReportMetrics?.PasswordAtRiskCount, + CriticalPasswordCount = request.ReportMetrics?.CriticalPasswordCount, + CriticalPasswordAtRiskCount = request.ReportMetrics?.CriticalPasswordAtRiskCount, + RevisionDate = DateTime.UtcNow + }; + + var data = await _organizationReportRepo.CreateAsync(organizationReport); + + _logger.LogInformation(Constants.BypassFiltersEventId, + "Successfully created organization report for organization {organizationId}, {organizationReportId}", + request.OrganizationId, data.Id); + + return data; + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync( + AddOrganizationReportRequest request) + { + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.ContentEncryptionKey)) + { + return (false, "Content Encryption Key is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs index 983fa71fd781..e1eeba0982c2 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs @@ -1,7 +1,6 @@ using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; using Microsoft.Extensions.Logging; namespace Bit.Core.Dirt.Reports.ReportFeatures; @@ -21,42 +20,8 @@ public GetOrganizationReportApplicationDataQuery( public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) { - try - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report application data for organization {organizationId} and report {reportId}", - organizationId, reportId); + var applicationDataResponse = await _organizationReportRepo.GetApplicationDataAsync(reportId); - if (organizationId == Guid.Empty) - { - _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportApplicationDataAsync called with empty OrganizationId"); - throw new BadRequestException("OrganizationId is required."); - } - - if (reportId == Guid.Empty) - { - _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportApplicationDataAsync called with empty ReportId"); - throw new BadRequestException("ReportId is required."); - } - - var applicationDataResponse = await _organizationReportRepo.GetApplicationDataAsync(reportId); - - if (applicationDataResponse == null) - { - _logger.LogWarning(Constants.BypassFiltersEventId, "No application data found for organization {organizationId} and report {reportId}", - organizationId, reportId); - throw new NotFoundException("Organization report application data not found."); - } - - _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved organization report application data for organization {organizationId} and report {reportId}", - organizationId, reportId); - - return applicationDataResponse; - } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) - { - _logger.LogError(ex, "Error fetching organization report application data for organization {organizationId} and report {reportId}", - organizationId, reportId); - throw; - } + return applicationDataResponse; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs new file mode 100644 index 000000000000..23128dd4fc9e --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs @@ -0,0 +1,50 @@ +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportDataV2Query : IGetOrganizationReportDataV2Query +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IOrganizationReportStorageService _storageService; + private readonly ILogger _logger; + + public GetOrganizationReportDataV2Query( + IOrganizationReportRepository organizationReportRepo, + IOrganizationReportStorageService storageService, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _storageService = storageService; + _logger = logger; + } + + public async Task GetOrganizationReportDataAsync( + Guid organizationId, + Guid reportId, + string reportFileId) + { + _logger.LogInformation(Constants.BypassFiltersEventId, + "Generating download URL for report data - organization {organizationId}, report {reportId}", + organizationId, reportId); + + if (string.IsNullOrEmpty(reportFileId)) + { + throw new BadRequestException("ReportFileId is required"); + } + + var report = await _organizationReportRepo.GetByIdAsync(reportId); + if (report == null || report.OrganizationId != organizationId) + { + throw new NotFoundException("Report not found"); + } + + var downloadUrl = await _storageService.GetReportDataDownloadUrlAsync(report, reportFileId); + + return new OrganizationReportDataFileStorageResponse { DownloadUrl = downloadUrl }; + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportV2Command.cs new file mode 100644 index 000000000000..04a2ac5d1812 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportV2Command.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface ICreateOrganizationReportV2Command +{ + Task CreateAsync(AddOrganizationReportRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataV2Query.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataV2Query.cs new file mode 100644 index 000000000000..e67ec0dec35c --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataV2Query.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportDataV2Query +{ + Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId, string reportFileId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataV2Command.cs new file mode 100644 index 000000000000..21d9f005e9dc --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataV2Command.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportDataV2Command +{ + Task GetUploadUrlAsync(UpdateOrganizationReportDataRequest request, string reportFileId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index f89ff977624f..0331d2ffff8c 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -23,5 +23,12 @@ public static void AddReportingServices(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // v2 file storage commands + services.AddScoped(); + services.AddScoped(); + + // v2 file storage queries + services.AddScoped(); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs index eecc84c522ed..f49f9a7fc204 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs @@ -11,5 +11,5 @@ public class AddOrganizationReportRequest public string? ApplicationData { get; set; } - public OrganizationReportMetricsRequest? Metrics { get; set; } + public OrganizationReportMetrics? ReportMetrics { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/OrganizationReportMetrics.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/OrganizationReportMetrics.cs new file mode 100644 index 000000000000..e01408a3d532 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/OrganizationReportMetrics.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class OrganizationReportMetrics +{ + [JsonPropertyName("totalApplicationCount")] + public int? ApplicationCount { get; set; } = null; + [JsonPropertyName("totalAtRiskApplicationCount")] + public int? ApplicationAtRiskCount { get; set; } = null; + [JsonPropertyName("totalCriticalApplicationCount")] + public int? CriticalApplicationCount { get; set; } = null; + [JsonPropertyName("totalCriticalAtRiskApplicationCount")] + public int? CriticalApplicationAtRiskCount { get; set; } = null; + [JsonPropertyName("totalMemberCount")] + public int? MemberCount { get; set; } = null; + [JsonPropertyName("totalAtRiskMemberCount")] + public int? MemberAtRiskCount { get; set; } = null; + [JsonPropertyName("totalCriticalMemberCount")] + public int? CriticalMemberCount { get; set; } = null; + [JsonPropertyName("totalCriticalAtRiskMemberCount")] + public int? CriticalMemberAtRiskCount { get; set; } = null; + [JsonPropertyName("totalPasswordCount")] + public int? PasswordCount { get; set; } = null; + [JsonPropertyName("totalAtRiskPasswordCount")] + public int? PasswordAtRiskCount { get; set; } = null; + [JsonPropertyName("totalCriticalPasswordCount")] + public int? CriticalPasswordCount { get; set; } = null; + [JsonPropertyName("totalCriticalAtRiskPasswordCount")] + public int? CriticalPasswordAtRiskCount { get; set; } = null; +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs index 673a3f2ab8e5..4489c4baedf5 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs @@ -1,11 +1,8 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class UpdateOrganizationReportDataRequest { public Guid OrganizationId { get; set; } public Guid ReportId { get; set; } - public string ReportData { get; set; } + public string? ReportData { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs index 27358537c280..1a63297663ee 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs @@ -5,5 +5,5 @@ public class UpdateOrganizationReportSummaryRequest public Guid OrganizationId { get; set; } public Guid ReportId { get; set; } public string? SummaryData { get; set; } - public OrganizationReportMetricsRequest? Metrics { get; set; } + public OrganizationReportMetrics? ReportMetrics { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs index f81d24c3d74a..c62cb42058e6 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs @@ -53,7 +53,7 @@ public async Task UpdateOrganizationReportDataAsync(UpdateOr throw new BadRequestException("Organization report does not belong to the specified organization"); } - var updatedReport = await _organizationReportRepo.UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData); + var updatedReport = await _organizationReportRepo.UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData ?? string.Empty); _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report data {reportId} for organization {organizationId}", request.ReportId, request.OrganizationId); diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs new file mode 100644 index 000000000000..ce1c6875c787 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs @@ -0,0 +1,49 @@ +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportDataV2Command : IUpdateOrganizationReportDataV2Command +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IOrganizationReportStorageService _storageService; + private readonly ILogger _logger; + + public UpdateOrganizationReportDataV2Command( + IOrganizationReportRepository organizationReportRepository, + IOrganizationReportStorageService storageService, + ILogger logger) + { + _organizationReportRepo = organizationReportRepository; + _storageService = storageService; + _logger = logger; + } + + public async Task GetUploadUrlAsync(UpdateOrganizationReportDataRequest request, string reportFileId) + { + _logger.LogInformation(Constants.BypassFiltersEventId, + "Generating upload URL for report data - organization {organizationId}, report {reportId}", + request.OrganizationId, request.ReportId); + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null || existingReport.OrganizationId != request.OrganizationId) + { + throw new NotFoundException("Report not found"); + } + + if (existingReport.FileId != reportFileId) + { + throw new NotFoundException("Report not found"); + } + + // Update revision date + existingReport.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(existingReport); + + return await _storageService.GetReportDataUploadUrlAsync(existingReport, reportFileId); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs index 5d0f2670ca76..86c1ee67a9ed 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs @@ -54,7 +54,7 @@ public async Task UpdateOrganizationReportSummaryAsync(Updat throw new BadRequestException("Organization report does not belong to the specified organization"); } - await _organizationReportRepo.UpdateMetricsAsync(request.ReportId, OrganizationReportMetricsData.From(request.OrganizationId, request.Metrics)); + await _organizationReportRepo.UpdateMetricsAsync(request.ReportId, OrganizationReportMetricsData.From(request.OrganizationId, request.ReportMetrics)); var updatedReport = await _organizationReportRepo.UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData ?? string.Empty); _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report summary {reportId} for organization {organizationId}", diff --git a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs new file mode 100644 index 000000000000..3a81a7eb87d2 --- /dev/null +++ b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs @@ -0,0 +1,63 @@ +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Azure.Storage.Sas; +using Bit.Core.Dirt.Entities; +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.Dirt.Reports.Services; + +public class AzureOrganizationReportStorageService : IOrganizationReportStorageService +{ + public const string ContainerName = "organization-reports"; + private static readonly TimeSpan _sasTokenLifetime = TimeSpan.FromMinutes(1); + + private readonly BlobServiceClient _blobServiceClient; + private BlobContainerClient? _containerClient; + + public FileUploadType FileUploadType => FileUploadType.Azure; + + public AzureOrganizationReportStorageService(GlobalSettings globalSettings) + { + _blobServiceClient = new BlobServiceClient(globalSettings.OrganizationReport.ConnectionString); + } + + public async Task GetReportDataUploadUrlAsync(OrganizationReport report, string reportFileId) + { + await InitAsync(); + var blobClient = _containerClient!.GetBlobClient(BlobPath(report, reportFileId, "report-data.json")); + return blobClient.GenerateSasUri( + BlobSasPermissions.Create | BlobSasPermissions.Write, + DateTime.UtcNow.Add(_sasTokenLifetime)).ToString(); + } + + public async Task GetReportDataDownloadUrlAsync(OrganizationReport report, string reportFileId) + { + await InitAsync(); + var blobClient = _containerClient!.GetBlobClient(BlobPath(report, reportFileId, "report-data.json")); + return blobClient.GenerateSasUri(BlobSasPermissions.Read, + DateTime.UtcNow.Add(_sasTokenLifetime)).ToString(); + } + + public async Task UploadReportDataAsync(OrganizationReport report, string reportFileId, Stream stream) + { + await InitAsync(); + var blobClient = _containerClient!.GetBlobClient(BlobPath(report, reportFileId, "report-data.json")); + await blobClient.UploadAsync(stream, overwrite: true); + } + + private static string BlobPath(OrganizationReport report, string reportFileId, string fileName) + { + var date = report.CreationDate.ToString("MM-dd-yyyy"); + return $"{report.OrganizationId}/{date}/{report.Id}/{reportFileId}/{fileName}"; + } + + private async Task InitAsync() + { + if (_containerClient == null) + { + _containerClient = _blobServiceClient.GetBlobContainerClient(ContainerName); + await _containerClient.CreateIfNotExistsAsync(PublicAccessType.None); + } + } +} diff --git a/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs new file mode 100644 index 000000000000..e43c965e688c --- /dev/null +++ b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs @@ -0,0 +1,16 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.Dirt.Reports.Services; + +public interface IOrganizationReportStorageService +{ + FileUploadType FileUploadType { get; } + + Task GetReportDataUploadUrlAsync(OrganizationReport report, string reportFileId); + + Task GetReportDataDownloadUrlAsync(OrganizationReport report, string reportFileId); + + Task UploadReportDataAsync(OrganizationReport report, string reportFileId, Stream stream); + +} diff --git a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs new file mode 100644 index 000000000000..b31ddd08c5d4 --- /dev/null +++ b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs @@ -0,0 +1,67 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.Dirt.Reports.Services; + +public class LocalOrganizationReportStorageService : IOrganizationReportStorageService +{ + private readonly string _baseDirPath; + private readonly string _baseUrl; + + public FileUploadType FileUploadType => FileUploadType.Direct; + + public LocalOrganizationReportStorageService(GlobalSettings globalSettings) + { + _baseDirPath = globalSettings.OrganizationReport.BaseDirectory; + _baseUrl = globalSettings.OrganizationReport.BaseUrl; + } + + public Task GetReportDataUploadUrlAsync(OrganizationReport report, string reportFileId) + => Task.FromResult($"/reports/v2/organizations/{report.OrganizationId}/{report.Id}/file/report-data"); + + public Task GetReportDataDownloadUrlAsync(OrganizationReport report, string reportFileId) + { + InitDir(); + return Task.FromResult($"{_baseUrl}/{RelativePath(report, reportFileId, "report-data.json")}"); + } + + public async Task UploadReportDataAsync(OrganizationReport report, string reportFileId, Stream stream) + => await WriteFileAsync(report, reportFileId, "report-data.json", stream); + + public Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId) + { + var dirPath = Path.Combine(_baseDirPath, report.OrganizationId.ToString(), + report.CreationDate.ToString("MM-dd-yyyy"), report.Id.ToString(), reportFileId); + if (Directory.Exists(dirPath)) + { + Directory.Delete(dirPath, true); + } + return Task.CompletedTask; + } + + private async Task WriteFileAsync(OrganizationReport report, string reportFileId, string fileName, Stream stream) + { + InitDir(); + var path = Path.Combine(_baseDirPath, RelativePath(report, reportFileId, fileName)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var fs = File.Create(path); + stream.Seek(0, SeekOrigin.Begin); + await stream.CopyToAsync(fs); + } + + private static string RelativePath(OrganizationReport report, string reportFileId, string fileName) + { + var date = report.CreationDate.ToString("MM-dd-yyyy"); + return Path.Combine(report.OrganizationId.ToString(), date, report.Id.ToString(), + reportFileId, fileName); + } + + private void InitDir() + { + if (!Directory.Exists(_baseDirPath)) + { + Directory.CreateDirectory(_baseDirPath); + } + } +} diff --git a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs new file mode 100644 index 000000000000..255da8713797 --- /dev/null +++ b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs @@ -0,0 +1,17 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.Dirt.Reports.Services; + +public class NoopOrganizationReportStorageService : IOrganizationReportStorageService +{ + public FileUploadType FileUploadType => FileUploadType.Direct; + + public Task GetReportDataUploadUrlAsync(OrganizationReport report, string reportFileId) => Task.FromResult(string.Empty); + + public Task GetReportDataDownloadUrlAsync(OrganizationReport report, string reportFileId) => Task.FromResult(string.Empty); + + public Task UploadReportDataAsync(OrganizationReport report, string reportFileId, Stream stream) => Task.CompletedTask; + + public Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId) => Task.CompletedTask; +} diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 6ccbd1ee850a..fab8690fe6bc 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -15,6 +15,7 @@ public GlobalSettings() BaseServiceUri = new BaseServiceUriSettings(this); Attachment = new FileStorageSettings(this, "attachments", "attachments"); Send = new FileStorageSettings(this, "attachments/send", "attachments/send"); + OrganizationReport = new FileStorageSettings(this, "reports/organization-reports", "reports/organization-reports"); DataProtection = new DataProtectionSettings(this); } @@ -62,6 +63,7 @@ public virtual string MailTemplateDirectory public virtual NotificationsSettings Notifications { get; set; } = new NotificationsSettings(); public virtual IFileStorageSettings Attachment { get; set; } public virtual FileStorageSettings Send { get; set; } + public virtual FileStorageSettings OrganizationReport { get; set; } public virtual IdentityServerSettings IdentityServer { get; set; } = new IdentityServerSettings(); public virtual DataProtectionSettings DataProtection { get; set; } public virtual NotificationHubPoolSettings NotificationHubPool { get; set; } = new(); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 988e88481383..bbaad8ab45d4 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -27,6 +27,7 @@ using Bit.Core.Billing.Services.Implementations; using Bit.Core.Billing.TrialInitiation; using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.HostedServices; @@ -364,6 +365,19 @@ public static void AddDefaultServices(this IServiceCollection services, GlobalSe { services.AddSingleton(); } + + if (CoreHelpers.SettingHasValue(globalSettings.OrganizationReport.ConnectionString)) + { + services.AddSingleton(); + } + else if (CoreHelpers.SettingHasValue(globalSettings.OrganizationReport.BaseDirectory)) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } } public static void AddOosServices(this IServiceCollection services) diff --git a/test/Common/AutoFixture/GlobalSettingsFixtures.cs b/test/Common/AutoFixture/GlobalSettingsFixtures.cs index 3a2a319eec37..04430be18f74 100644 --- a/test/Common/AutoFixture/GlobalSettingsFixtures.cs +++ b/test/Common/AutoFixture/GlobalSettingsFixtures.cs @@ -10,6 +10,7 @@ public void Customize(IFixture fixture) .Without(s => s.BaseServiceUri) .Without(s => s.Attachment) .Without(s => s.Send) + .Without(s => s.OrganizationReport) .Without(s => s.DataProtection)); } } diff --git a/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs new file mode 100644 index 000000000000..3da18e0e70fb --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs @@ -0,0 +1,137 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class CreateOrganizationReportV2CommandTests +{ + [Theory] + [BitAutoData] + public async Task CreateAsync_Success_ReturnsReportAndGeneratesFileId( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(r => r.ContentEncryptionKey, "test-encryption-key") + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(c => c.Arg()); + + // Act + var report = await sutProvider.Sut.CreateAsync(request); + + // Assert + Assert.NotNull(report); + Assert.NotNull(report.FileId); + Assert.NotEmpty(report.FileId); + Assert.Equal(32, report.FileId.Length); // SecureRandomString(32) + Assert.Matches("^[a-z0-9]+$", report.FileId); // Only lowercase alphanumeric + + Assert.Empty(report.ReportData); + Assert.Equal(request.SummaryData, report.SummaryData); + Assert.Equal(request.ApplicationData, report.ApplicationData); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(Arg.Is(r => + r.OrganizationId == request.OrganizationId && + r.ReportData == string.Empty && + r.SummaryData == request.SummaryData && + r.ApplicationData == request.ApplicationData && + r.FileId != null && r.FileId.Length == 32 && + r.ContentEncryptionKey == "test-encryption-key")); + } + + [Theory] + [BitAutoData] + public async Task CreateAsync_InvalidOrganization_ThrowsBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(r => r.ContentEncryptionKey, "test-key") + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(null as Organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.CreateAsync(request)); + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task CreateAsync_MissingContentEncryptionKey_ThrowsBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(r => r.ContentEncryptionKey, string.Empty) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(fixture.Create()); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.CreateAsync(request)); + Assert.Equal("Content Encryption Key is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task CreateAsync_WithMetrics_StoresMetricsCorrectly( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var metrics = fixture.Build() + .With(m => m.ApplicationCount, 100) + .With(m => m.MemberCount, 50) + .Create(); + + var request = fixture.Build() + .With(r => r.ContentEncryptionKey, "test-key") + .With(r => r.ReportMetrics, metrics) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(c => c.Arg()); + + // Act + var report = await sutProvider.Sut.CreateAsync(request); + + // Assert + Assert.Equal(100, report.ApplicationCount); + Assert.Equal(50, report.MemberCount); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs new file mode 100644 index 000000000000..9f973b071b36 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs @@ -0,0 +1,114 @@ +using AutoFixture; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class GetOrganizationReportDataV2QueryTests +{ + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_Success_ReturnsDownloadUrl( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var reportFileId = "test-file-id-plaintext"; + var expectedUrl = "https://blob.storage.azure.com/sas-url"; + + var report = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, organizationId) + .With(r => r.FileId, "encrypted-file-id") + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(report); + + sutProvider.GetDependency() + .GetReportDataDownloadUrlAsync(report, reportFileId) + .Returns(expectedUrl); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedUrl, result.DownloadUrl); + + await sutProvider.GetDependency() + .Received(1) + .GetReportDataDownloadUrlAsync(report, reportFileId); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_ReportNotFound_ThrowsNotFoundException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var reportFileId = "test-file-id"; + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(null as OrganizationReport); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId)); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_OrganizationMismatch_ThrowsNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = Guid.NewGuid(); + var differentOrgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var reportFileId = "test-file-id"; + + var report = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, differentOrgId) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(report); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId)); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_MissingReportFileId_ThrowsBadRequestException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + string? reportFileId = null; + + // Act & Assert + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId!)); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs new file mode 100644 index 000000000000..ba0e55d7576f --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs @@ -0,0 +1,83 @@ +using AutoFixture; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class UpdateOrganizationReportDataV2CommandTests +{ + [Theory] + [BitAutoData] + public async Task GetUploadUrlAsync_WithMismatchedFileId_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .With(x => x.FileId, "stored-file-id") + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetUploadUrlAsync(request, "attacker-supplied-file-id")); + + Assert.Equal("Report not found", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task GetUploadUrlAsync_WithNonExistentReport_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns((OrganizationReport)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetUploadUrlAsync(request, "any-file-id")); + + Assert.Equal("Report not found", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task GetUploadUrlAsync_WithMismatchedOrgId_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, Guid.NewGuid()) // Different org ID + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetUploadUrlAsync(request, "any-file-id")); + } +} diff --git a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs new file mode 100644 index 000000000000..b2a153fffccf --- /dev/null +++ b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs @@ -0,0 +1,113 @@ +using AutoFixture; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Dirt.Reports.Services; + +[SutProviderCustomize] +public class AzureOrganizationReportStorageServiceTests +{ + private static Core.Settings.GlobalSettings GetGlobalSettings() + { + var globalSettings = new Core.Settings.GlobalSettings(); + globalSettings.OrganizationReport.ConnectionString = "UseDevelopmentStorage=true"; + return globalSettings; + } + + [Fact] + public void FileUploadType_ReturnsAzure() + { + // Arrange + var globalSettings = GetGlobalSettings(); + var sut = new AzureOrganizationReportStorageService(globalSettings); + + // Act & Assert + Assert.Equal(FileUploadType.Azure, sut.FileUploadType); + } + + [Fact] + public async Task GetReportDataUploadUrlAsync_ReturnsValidSasUrl() + { + // Arrange + var fixture = new Fixture(); + var globalSettings = GetGlobalSettings(); + var sut = new AzureOrganizationReportStorageService(globalSettings); + + var report = fixture.Build() + .With(r => r.OrganizationId, Guid.NewGuid()) + .With(r => r.Id, Guid.NewGuid()) + .With(r => r.CreationDate, new DateTime(2026, 2, 17)) + .Create(); + + var reportFileId = "test-file-id-123"; + + // Act + var url = await sut.GetReportDataUploadUrlAsync(report, reportFileId); + + // Assert + Assert.NotNull(url); + Assert.NotEmpty(url); + Assert.Contains("report-data.json", url); + Assert.Contains("sig=", url); // SAS signature + Assert.Contains("sp=", url); // Permissions + Assert.Contains("se=", url); // Expiry + } + + [Fact] + public async Task GetReportDataDownloadUrlAsync_ReturnsValidSasUrl() + { + // Arrange + var fixture = new Fixture(); + var globalSettings = GetGlobalSettings(); + var sut = new AzureOrganizationReportStorageService(globalSettings); + + var report = fixture.Build() + .With(r => r.OrganizationId, Guid.NewGuid()) + .With(r => r.Id, Guid.NewGuid()) + .With(r => r.CreationDate, new DateTime(2026, 2, 17)) + .Create(); + + var reportFileId = "test-file-id-123"; + + // Act + var url = await sut.GetReportDataDownloadUrlAsync(report, reportFileId); + + // Assert + Assert.NotNull(url); + Assert.NotEmpty(url); + Assert.Contains("report-data.json", url); + Assert.Contains("sig=", url); // SAS signature + Assert.Contains("sp=", url); // Permissions (should be read-only) + } + + [Fact] + public async Task BlobPath_FormatsCorrectly() + { + // Arrange + var fixture = new Fixture(); + var globalSettings = GetGlobalSettings(); + var sut = new AzureOrganizationReportStorageService(globalSettings); + + var orgId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var reportId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var creationDate = new DateTime(2026, 2, 17); + var reportFileId = "abc123xyz"; + + var report = fixture.Build() + .With(r => r.OrganizationId, orgId) + .With(r => r.Id, reportId) + .With(r => r.CreationDate, creationDate) + .Create(); + + // Act + var url = await sut.GetReportDataUploadUrlAsync(report, reportFileId); + + // Assert + // Expected path: {orgId}/{MM-dd-yyyy}/{reportId}/{fileId}/report-data.json + var expectedPath = $"{orgId}/02-17-2026/{reportId}/{reportFileId}/report-data.json"; + Assert.Contains(expectedPath, url); + } +} diff --git a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs new file mode 100644 index 000000000000..c97be04046cb --- /dev/null +++ b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs @@ -0,0 +1,184 @@ +using AutoFixture; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Dirt.Reports.Services; + +[SutProviderCustomize] +public class LocalOrganizationReportStorageServiceTests +{ + private static Core.Settings.GlobalSettings GetGlobalSettings() + { + var globalSettings = new Core.Settings.GlobalSettings(); + globalSettings.OrganizationReport.BaseDirectory = "/tmp/bitwarden-test/reports"; + globalSettings.OrganizationReport.BaseUrl = "https://localhost/reports"; + return globalSettings; + } + + [Fact] + public void FileUploadType_ReturnsDirect() + { + // Arrange + var globalSettings = GetGlobalSettings(); + var sut = new LocalOrganizationReportStorageService(globalSettings); + + // Act & Assert + Assert.Equal(FileUploadType.Direct, sut.FileUploadType); + } + + [Fact] + public async Task GetReportDataUploadUrlAsync_ReturnsApiEndpoint() + { + // Arrange + var fixture = new Fixture(); + var globalSettings = GetGlobalSettings(); + var sut = new LocalOrganizationReportStorageService(globalSettings); + + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var report = fixture.Build() + .With(r => r.OrganizationId, orgId) + .With(r => r.Id, reportId) + .Create(); + + var reportFileId = "test-file-id"; + + // Act + var url = await sut.GetReportDataUploadUrlAsync(report, reportFileId); + + // Assert + Assert.Equal($"/reports/v2/organizations/{orgId}/{reportId}/file/report-data", url); + } + + [Fact] + public async Task GetReportDataDownloadUrlAsync_ReturnsBaseUrlWithPath() + { + // Arrange + var fixture = new Fixture(); + var globalSettings = GetGlobalSettings(); + var sut = new LocalOrganizationReportStorageService(globalSettings); + + var orgId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var reportId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var creationDate = new DateTime(2026, 2, 17); + var reportFileId = "abc123"; + + var report = fixture.Build() + .With(r => r.OrganizationId, orgId) + .With(r => r.Id, reportId) + .With(r => r.CreationDate, creationDate) + .Create(); + + // Act + var url = await sut.GetReportDataDownloadUrlAsync(report, reportFileId); + + // Assert + Assert.StartsWith("https://localhost/reports/", url); + Assert.Contains($"{orgId}", url); + Assert.Contains("02-17-2026", url); // Date format + Assert.Contains($"{reportId}", url); + Assert.Contains(reportFileId, url); + Assert.EndsWith("report-data.json", url); + } + + [Theory] + [InlineData("../../etc/malicious")] + [InlineData("../../../tmp/evil")] + public async Task UploadReportDataAsync_WithPathTraversalPayload_WritesOutsideBaseDirectory(string maliciousFileId) + { + // Arrange - demonstrates the path traversal vulnerability that is mitigated + // by validating reportFileId matches report.FileId at the controller/command layer + var fixture = new Fixture(); + var tempDir = Path.Combine(Path.GetTempPath(), "bitwarden-test-" + Guid.NewGuid()); + + var globalSettings = new Core.Settings.GlobalSettings(); + globalSettings.OrganizationReport.BaseDirectory = tempDir; + globalSettings.OrganizationReport.BaseUrl = "https://localhost/reports"; + + var sut = new LocalOrganizationReportStorageService(globalSettings); + + var report = fixture.Build() + .With(r => r.OrganizationId, Guid.NewGuid()) + .With(r => r.Id, Guid.NewGuid()) + .With(r => r.CreationDate, DateTime.UtcNow) + .Create(); + + var testData = "malicious content"; + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(testData)); + + try + { + // Act + await sut.UploadReportDataAsync(report, maliciousFileId, stream); + + // Assert - the file is written at a path that escapes the intended report directory + var intendedBaseDir = Path.Combine(tempDir, report.OrganizationId.ToString(), + report.CreationDate.ToString("MM-dd-yyyy"), report.Id.ToString()); + var actualFilePath = Path.Combine(intendedBaseDir, maliciousFileId, "report-data.json"); + var resolvedPath = Path.GetFullPath(actualFilePath); + + // This demonstrates the vulnerability: the resolved path escapes the base directory + Assert.False(resolvedPath.StartsWith(Path.GetFullPath(intendedBaseDir))); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task UploadReportDataAsync_CreatesDirectoryAndWritesFile() + { + // Arrange + var fixture = new Fixture(); + var tempDir = Path.Combine(Path.GetTempPath(), "bitwarden-test-" + Guid.NewGuid()); + + var globalSettings = new Core.Settings.GlobalSettings(); + globalSettings.OrganizationReport.BaseDirectory = tempDir; + globalSettings.OrganizationReport.BaseUrl = "https://localhost/reports"; + + var sut = new LocalOrganizationReportStorageService(globalSettings); + + var report = fixture.Build() + .With(r => r.OrganizationId, Guid.NewGuid()) + .With(r => r.Id, Guid.NewGuid()) + .With(r => r.CreationDate, DateTime.UtcNow) + .Create(); + + var reportFileId = "test-file-123"; + var testData = "test report data content"; + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(testData)); + + try + { + // Act + await sut.UploadReportDataAsync(report, reportFileId, stream); + + // Assert + var expectedDir = Path.Combine(tempDir, report.OrganizationId.ToString(), + report.CreationDate.ToString("MM-dd-yyyy"), report.Id.ToString(), reportFileId); + Assert.True(Directory.Exists(expectedDir)); + + var expectedFile = Path.Combine(expectedDir, "report-data.json"); + Assert.True(File.Exists(expectedFile)); + + var fileContent = await File.ReadAllTextAsync(expectedFile); + Assert.Equal(testData, fileContent); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } +} From 24dd095c435b5be9fddd9d59324895bcaf9199d8 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Thu, 26 Feb 2026 07:29:14 -0600 Subject: [PATCH 02/85] PM-31923 changing approach to match others in codebase --- .../OrganizationReportsV2Controller.cs | 33 ++++- .../OrganizationReportResponseModel.cs | 2 - .../OrganizationReportV2ResponseModel.cs | 4 +- src/Core/Dirt/Entities/OrganizationReport.cs | 20 ++- src/Core/Dirt/Enums/OrganizationReportType.cs | 7 ++ .../Models/Data/OrganizationReportFileData.cs | 20 +++ .../CreateOrganizationReportV2Command.cs | 12 +- .../GetOrganizationReportDataV2Query.cs | 8 +- .../UpdateOrganizationReportDataV2Command.cs | 5 +- .../AzureOrganizationReportStorageService.cs | 56 +++++++-- .../IOrganizationReportStorageService.cs | 8 +- .../LocalOrganizationReportStorageService.cs | 33 +++-- .../NoopOrganizationReportStorageService.cs | 9 +- src/Core/Settings/GlobalSettings.cs | 2 +- .../CreateOrganizationReportV2CommandTests.cs | 21 ++-- .../GetOrganizationReportDataV2QueryTests.cs | 67 +++++++--- ...ateOrganizationReportDataV2CommandTests.cs | 31 +++-- ...reOrganizationReportStorageServiceTests.cs | 51 ++++---- ...alOrganizationReportStorageServiceTests.cs | 115 ++++++++++++++++-- 19 files changed, 399 insertions(+), 105 deletions(-) create mode 100644 src/Core/Dirt/Enums/OrganizationReportType.cs create mode 100644 src/Core/Dirt/Models/Data/OrganizationReportFileData.cs diff --git a/src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs b/src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs index fca68b3c1d3e..13e76734b0c3 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs @@ -5,6 +5,7 @@ using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Utilities; @@ -25,6 +26,7 @@ public class OrganizationReportsV2Controller : Controller private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; private readonly IGetOrganizationReportDataV2Query _getDataQuery; private readonly IUpdateOrganizationReportCommand _updateOrganizationReportCommand; + private readonly IOrganizationReportRepository _organizationReportRepo; public OrganizationReportsV2Controller( ICurrentContext currentContext, @@ -34,7 +36,8 @@ public OrganizationReportsV2Controller( IUpdateOrganizationReportDataV2Command updateDataCommand, IGetOrganizationReportQuery getOrganizationReportQuery, IGetOrganizationReportDataV2Query getDataQuery, - IUpdateOrganizationReportCommand updateOrganizationReportCommand) + IUpdateOrganizationReportCommand updateOrganizationReportCommand, + IOrganizationReportRepository organizationReportRepo) { _currentContext = currentContext; _applicationCacheService = applicationCacheService; @@ -44,6 +47,7 @@ public OrganizationReportsV2Controller( _getOrganizationReportQuery = getOrganizationReportQuery; _getDataQuery = getDataQuery; _updateOrganizationReportCommand = updateOrganizationReportCommand; + _organizationReportRepo = organizationReportRepo; } private async Task AuthorizeAsync(Guid organizationId) @@ -79,10 +83,13 @@ public async Task CreateOrganizationReportAsy var report = await _createCommand.CreateAsync(request); + var fileData = report.GetReportFileData()!; + return new OrganizationReportV2ResponseModel { - ReportDataUploadUrl = await _storageService.GetReportDataUploadUrlAsync(report, report.FileId!), - ReportResponse = new OrganizationReportResponseModel(report) + ReportDataUploadUrl = await _storageService.GetReportDataUploadUrlAsync(report, fileData), + ReportResponse = new OrganizationReportResponseModel(report), + FileUploadType = _storageService.FileUploadType }; } @@ -129,7 +136,8 @@ public async Task GetReportDataUploadUrlAsync return new OrganizationReportV2ResponseModel { ReportDataUploadUrl = uploadUrl, - ReportResponse = new OrganizationReportResponseModel(report) + ReportResponse = new OrganizationReportResponseModel(report), + FileUploadType = _storageService.FileUploadType }; } @@ -157,14 +165,27 @@ public async Task UploadReportDataAsync(Guid organizationId, Guid reportId, [Fro throw new BadRequestException("Invalid report ID"); } - if (report.FileId != reportFileId) + var fileData = report.GetReportFileData(); + if (fileData == null || fileData.Id != reportFileId) { throw new NotFoundException(); } await Request.GetFileAsync(async (stream) => { - await _storageService.UploadReportDataAsync(report, reportFileId, stream); + await _storageService.UploadReportDataAsync(report, fileData, stream); }); + + var (valid, length) = await _storageService.ValidateFileAsync(report, fileData, 0, Constants.FileSize501mb); + if (!valid) + { + throw new BadRequestException("File received does not match expected constraints."); + } + + fileData.Validated = true; + fileData.Size = length; + report.SetReportFileData(fileData); + report.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(report); } } diff --git a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs index d40901934978..e477e5b806a7 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs @@ -13,7 +13,6 @@ public class OrganizationReportResponseModel public int? PasswordCount { get; set; } public int? PasswordAtRiskCount { get; set; } public int? MemberCount { get; set; } - public string? FileId { get; set; } public DateTime? CreationDate { get; set; } = null; public DateTime? RevisionDate { get; set; } = null; @@ -33,7 +32,6 @@ public OrganizationReportResponseModel(OrganizationReport organizationReport) PasswordCount = organizationReport.PasswordCount; PasswordAtRiskCount = organizationReport.PasswordAtRiskCount; MemberCount = organizationReport.MemberCount; - FileId = organizationReport.FileId; CreationDate = organizationReport.CreationDate; RevisionDate = organizationReport.RevisionDate; } diff --git a/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs index 3f5e40a76cde..afadcd2f8db9 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs @@ -1,4 +1,5 @@ -using Bit.Core.Models.Api; +using Bit.Core.Enums; +using Bit.Core.Models.Api; namespace Bit.Api.Dirt.Models.Response; @@ -8,4 +9,5 @@ public OrganizationReportV2ResponseModel() : base("organizationReport-v2") { } public string ReportDataUploadUrl { get; set; } = string.Empty; public OrganizationReportResponseModel ReportResponse { get; set; } = null!; + public FileUploadType FileUploadType { get; set; } } diff --git a/src/Core/Dirt/Entities/OrganizationReport.cs b/src/Core/Dirt/Entities/OrganizationReport.cs index 962618ddd5ff..81c9dd6e500a 100644 --- a/src/Core/Dirt/Entities/OrganizationReport.cs +++ b/src/Core/Dirt/Entities/OrganizationReport.cs @@ -1,5 +1,8 @@ #nullable enable +using System.Text.Json; +using Bit.Core.Dirt.Enums; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Entities; using Bit.Core.Utilities; @@ -27,7 +30,22 @@ public class OrganizationReport : ITableObject public int? PasswordAtRiskCount { get; set; } public int? CriticalPasswordCount { get; set; } public int? CriticalPasswordAtRiskCount { get; set; } - public string? FileId { get; set; } + public OrganizationReportType Type { get; set; } + + public OrganizationReportFileData? GetReportFileData() + { + if (string.IsNullOrWhiteSpace(ReportData)) + { + return null; + } + + return JsonSerializer.Deserialize(ReportData); + } + + public void SetReportFileData(OrganizationReportFileData data) + { + ReportData = JsonSerializer.Serialize(data, JsonHelpers.IgnoreWritingNull); + } public void SetNewId() { diff --git a/src/Core/Dirt/Enums/OrganizationReportType.cs b/src/Core/Dirt/Enums/OrganizationReportType.cs new file mode 100644 index 000000000000..ea6317180524 --- /dev/null +++ b/src/Core/Dirt/Enums/OrganizationReportType.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Dirt.Enums; + +public enum OrganizationReportType : byte +{ + Data = 0, + File = 1 +} diff --git a/src/Core/Dirt/Models/Data/OrganizationReportFileData.cs b/src/Core/Dirt/Models/Data/OrganizationReportFileData.cs new file mode 100644 index 000000000000..78c651867d45 --- /dev/null +++ b/src/Core/Dirt/Models/Data/OrganizationReportFileData.cs @@ -0,0 +1,20 @@ +#nullable enable + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using static System.Text.Json.Serialization.JsonNumberHandling; + +namespace Bit.Core.Dirt.Models.Data; + +public class OrganizationReportFileData +{ + [JsonNumberHandling(WriteAsString | AllowReadingFromString)] + public long Size { get; set; } + + [DisallowNull] + public string? Id { get; set; } + + public string FileName { get; set; } = "report-data.json"; + + public bool Validated { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs index f106e4d15476..54ce4070f2d8 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs @@ -1,4 +1,6 @@ using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Enums; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Repositories; @@ -39,17 +41,20 @@ public async Task CreateAsync(AddOrganizationReportRequest r throw new BadRequestException(errorMessage); } - var reportFileId = CoreHelpers.SecureRandomString(32, upper: false, special: false); + var fileData = new OrganizationReportFileData + { + Id = CoreHelpers.SecureRandomString(32, upper: false, special: false), + Validated = false + }; var organizationReport = new OrganizationReport { OrganizationId = request.OrganizationId, - ReportData = string.Empty, + Type = OrganizationReportType.File, CreationDate = DateTime.UtcNow, ContentEncryptionKey = request.ContentEncryptionKey ?? string.Empty, SummaryData = request.SummaryData, ApplicationData = request.ApplicationData, - FileId = reportFileId, ApplicationCount = request.ReportMetrics?.ApplicationCount, ApplicationAtRiskCount = request.ReportMetrics?.ApplicationAtRiskCount, CriticalApplicationCount = request.ReportMetrics?.CriticalApplicationCount, @@ -64,6 +69,7 @@ public async Task CreateAsync(AddOrganizationReportRequest r CriticalPasswordAtRiskCount = request.ReportMetrics?.CriticalPasswordAtRiskCount, RevisionDate = DateTime.UtcNow }; + organizationReport.SetReportFileData(fileData); var data = await _organizationReportRepo.CreateAsync(organizationReport); diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs index 23128dd4fc9e..2e231d7f073e 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs @@ -43,7 +43,13 @@ public async Task GetOrganizationRepo throw new NotFoundException("Report not found"); } - var downloadUrl = await _storageService.GetReportDataDownloadUrlAsync(report, reportFileId); + var fileData = report.GetReportFileData(); + if (fileData == null) + { + throw new NotFoundException("Report file data not found"); + } + + var downloadUrl = await _storageService.GetReportDataDownloadUrlAsync(report, fileData); return new OrganizationReportDataFileStorageResponse { DownloadUrl = downloadUrl }; } diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs index ce1c6875c787..f4d6bbc85299 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs @@ -35,7 +35,8 @@ public async Task GetUploadUrlAsync(UpdateOrganizationReportDataRequest throw new NotFoundException("Report not found"); } - if (existingReport.FileId != reportFileId) + var fileData = existingReport.GetReportFileData(); + if (fileData == null || fileData.Id != reportFileId) { throw new NotFoundException("Report not found"); } @@ -44,6 +45,6 @@ public async Task GetUploadUrlAsync(UpdateOrganizationReportDataRequest existingReport.RevisionDate = DateTime.UtcNow; await _organizationReportRepo.ReplaceAsync(existingReport); - return await _storageService.GetReportDataUploadUrlAsync(existingReport, reportFileId); + return await _storageService.GetReportDataUploadUrlAsync(existingReport, fileData); } } diff --git a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs index 3a81a7eb87d2..8698c87087e0 100644 --- a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs @@ -2,8 +2,10 @@ using Azure.Storage.Blobs.Models; using Azure.Storage.Sas; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Enums; using Bit.Core.Settings; +using Microsoft.Extensions.Logging; namespace Bit.Core.Dirt.Reports.Services; @@ -13,43 +15,79 @@ public class AzureOrganizationReportStorageService : IOrganizationReportStorageS private static readonly TimeSpan _sasTokenLifetime = TimeSpan.FromMinutes(1); private readonly BlobServiceClient _blobServiceClient; + private readonly ILogger _logger; private BlobContainerClient? _containerClient; public FileUploadType FileUploadType => FileUploadType.Azure; - public AzureOrganizationReportStorageService(GlobalSettings globalSettings) + public AzureOrganizationReportStorageService( + GlobalSettings globalSettings, + ILogger logger) { _blobServiceClient = new BlobServiceClient(globalSettings.OrganizationReport.ConnectionString); + _logger = logger; } - public async Task GetReportDataUploadUrlAsync(OrganizationReport report, string reportFileId) + public async Task GetReportDataUploadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) { await InitAsync(); - var blobClient = _containerClient!.GetBlobClient(BlobPath(report, reportFileId, "report-data.json")); + var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); return blobClient.GenerateSasUri( BlobSasPermissions.Create | BlobSasPermissions.Write, DateTime.UtcNow.Add(_sasTokenLifetime)).ToString(); } - public async Task GetReportDataDownloadUrlAsync(OrganizationReport report, string reportFileId) + public async Task GetReportDataDownloadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) { await InitAsync(); - var blobClient = _containerClient!.GetBlobClient(BlobPath(report, reportFileId, "report-data.json")); + var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); return blobClient.GenerateSasUri(BlobSasPermissions.Read, DateTime.UtcNow.Add(_sasTokenLifetime)).ToString(); } - public async Task UploadReportDataAsync(OrganizationReport report, string reportFileId, Stream stream) + public async Task UploadReportDataAsync(OrganizationReport report, OrganizationReportFileData fileData, Stream stream) { await InitAsync(); - var blobClient = _containerClient!.GetBlobClient(BlobPath(report, reportFileId, "report-data.json")); + var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); await blobClient.UploadAsync(stream, overwrite: true); } - private static string BlobPath(OrganizationReport report, string reportFileId, string fileName) + public async Task<(bool valid, long length)> ValidateFileAsync( + OrganizationReport report, OrganizationReportFileData fileData, long minimum, long maximum) + { + await InitAsync(); + + var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); + + try + { + var blobProperties = await blobClient.GetPropertiesAsync(); + var metadata = blobProperties.Value.Metadata; + metadata["organizationId"] = report.OrganizationId.ToString(); + await blobClient.SetMetadataAsync(metadata); + + var headers = new BlobHttpHeaders + { + ContentDisposition = $"attachment; filename=\"{fileData.FileName}\"" + }; + await blobClient.SetHttpHeadersAsync(headers); + + var length = blobProperties.Value.ContentLength; + var valid = minimum <= length && length <= maximum; + + return (valid, length); + } + catch (Exception ex) + { + _logger.LogError(ex, "A storage operation failed in {MethodName}", nameof(ValidateFileAsync)); + return (false, -1); + } + } + + private static string BlobPath(OrganizationReport report, string fileId, string fileName) { var date = report.CreationDate.ToString("MM-dd-yyyy"); - return $"{report.OrganizationId}/{date}/{report.Id}/{reportFileId}/{fileName}"; + return $"{report.OrganizationId}/{date}/{report.Id}/{fileId}/{fileName}"; } private async Task InitAsync() diff --git a/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs index e43c965e688c..948239685a68 100644 --- a/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs @@ -1,4 +1,5 @@ using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Enums; namespace Bit.Core.Dirt.Reports.Services; @@ -7,10 +8,11 @@ public interface IOrganizationReportStorageService { FileUploadType FileUploadType { get; } - Task GetReportDataUploadUrlAsync(OrganizationReport report, string reportFileId); + Task GetReportDataUploadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData); - Task GetReportDataDownloadUrlAsync(OrganizationReport report, string reportFileId); + Task GetReportDataDownloadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData); - Task UploadReportDataAsync(OrganizationReport report, string reportFileId, Stream stream); + Task UploadReportDataAsync(OrganizationReport report, OrganizationReportFileData fileData, Stream stream); + Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, OrganizationReportFileData fileData, long minimum, long maximum); } diff --git a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs index b31ddd08c5d4..0c827da35521 100644 --- a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs @@ -1,4 +1,5 @@ using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Enums; using Bit.Core.Settings; @@ -17,17 +18,31 @@ public LocalOrganizationReportStorageService(GlobalSettings globalSettings) _baseUrl = globalSettings.OrganizationReport.BaseUrl; } - public Task GetReportDataUploadUrlAsync(OrganizationReport report, string reportFileId) + public Task GetReportDataUploadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) => Task.FromResult($"/reports/v2/organizations/{report.OrganizationId}/{report.Id}/file/report-data"); - public Task GetReportDataDownloadUrlAsync(OrganizationReport report, string reportFileId) + public Task GetReportDataDownloadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) { InitDir(); - return Task.FromResult($"{_baseUrl}/{RelativePath(report, reportFileId, "report-data.json")}"); + return Task.FromResult($"{_baseUrl}/{RelativePath(report, fileData.Id!, fileData.FileName)}"); } - public async Task UploadReportDataAsync(OrganizationReport report, string reportFileId, Stream stream) - => await WriteFileAsync(report, reportFileId, "report-data.json", stream); + public async Task UploadReportDataAsync(OrganizationReport report, OrganizationReportFileData fileData, Stream stream) + => await WriteFileAsync(report, fileData.Id!, fileData.FileName, stream); + + public Task<(bool valid, long length)> ValidateFileAsync( + OrganizationReport report, OrganizationReportFileData fileData, long minimum, long maximum) + { + var path = Path.Combine(_baseDirPath, RelativePath(report, fileData.Id!, fileData.FileName)); + if (!File.Exists(path)) + { + return Task.FromResult((false, -1L)); + } + + var length = new FileInfo(path).Length; + var valid = minimum <= length && length <= maximum; + return Task.FromResult((valid, length)); + } public Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId) { @@ -40,21 +55,21 @@ public Task DeleteReportFilesAsync(OrganizationReport report, string reportFileI return Task.CompletedTask; } - private async Task WriteFileAsync(OrganizationReport report, string reportFileId, string fileName, Stream stream) + private async Task WriteFileAsync(OrganizationReport report, string fileId, string fileName, Stream stream) { InitDir(); - var path = Path.Combine(_baseDirPath, RelativePath(report, reportFileId, fileName)); + var path = Path.Combine(_baseDirPath, RelativePath(report, fileId, fileName)); Directory.CreateDirectory(Path.GetDirectoryName(path)!); using var fs = File.Create(path); stream.Seek(0, SeekOrigin.Begin); await stream.CopyToAsync(fs); } - private static string RelativePath(OrganizationReport report, string reportFileId, string fileName) + private static string RelativePath(OrganizationReport report, string fileId, string fileName) { var date = report.CreationDate.ToString("MM-dd-yyyy"); return Path.Combine(report.OrganizationId.ToString(), date, report.Id.ToString(), - reportFileId, fileName); + fileId, fileName); } private void InitDir() diff --git a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs index 255da8713797..69726afdb063 100644 --- a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs @@ -1,4 +1,5 @@ using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Enums; namespace Bit.Core.Dirt.Reports.Services; @@ -7,11 +8,11 @@ public class NoopOrganizationReportStorageService : IOrganizationReportStorageSe { public FileUploadType FileUploadType => FileUploadType.Direct; - public Task GetReportDataUploadUrlAsync(OrganizationReport report, string reportFileId) => Task.FromResult(string.Empty); + public Task GetReportDataUploadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) => Task.FromResult(string.Empty); - public Task GetReportDataDownloadUrlAsync(OrganizationReport report, string reportFileId) => Task.FromResult(string.Empty); + public Task GetReportDataDownloadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) => Task.FromResult(string.Empty); - public Task UploadReportDataAsync(OrganizationReport report, string reportFileId, Stream stream) => Task.CompletedTask; + public Task UploadReportDataAsync(OrganizationReport report, OrganizationReportFileData fileData, Stream stream) => Task.CompletedTask; - public Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId) => Task.CompletedTask; + public Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, OrganizationReportFileData fileData, long minimum, long maximum) => Task.FromResult((true, 0L)); } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index fab8690fe6bc..b251a3695535 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -15,7 +15,7 @@ public GlobalSettings() BaseServiceUri = new BaseServiceUriSettings(this); Attachment = new FileStorageSettings(this, "attachments", "attachments"); Send = new FileStorageSettings(this, "attachments/send", "attachments/send"); - OrganizationReport = new FileStorageSettings(this, "reports/organization-reports", "reports/organization-reports"); + OrganizationReport = new FileStorageSettings(this, "attachments/reports", "attachments/reports"); DataProtection = new DataProtectionSettings(this); } diff --git a/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs index 3da18e0e70fb..8f04afd1a490 100644 --- a/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs @@ -1,6 +1,7 @@ using AutoFixture; using Bit.Core.AdminConsole.Entities; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Enums; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Repositories; @@ -18,7 +19,7 @@ public class CreateOrganizationReportV2CommandTests { [Theory] [BitAutoData] - public async Task CreateAsync_Success_ReturnsReportAndGeneratesFileId( + public async Task CreateAsync_Success_ReturnsReportWithSerializedFileData( SutProvider sutProvider) { // Arrange @@ -40,12 +41,17 @@ public async Task CreateAsync_Success_ReturnsReportAndGeneratesFileId( // Assert Assert.NotNull(report); - Assert.NotNull(report.FileId); - Assert.NotEmpty(report.FileId); - Assert.Equal(32, report.FileId.Length); // SecureRandomString(32) - Assert.Matches("^[a-z0-9]+$", report.FileId); // Only lowercase alphanumeric + Assert.Equal(OrganizationReportType.File, report.Type); + + // ReportData should contain serialized OrganizationReportFileData + Assert.NotEmpty(report.ReportData); + var fileData = report.GetReportFileData(); + Assert.NotNull(fileData); + Assert.NotNull(fileData.Id); + Assert.Equal(32, fileData.Id.Length); + Assert.Matches("^[a-z0-9]+$", fileData.Id); + Assert.False(fileData.Validated); - Assert.Empty(report.ReportData); Assert.Equal(request.SummaryData, report.SummaryData); Assert.Equal(request.ApplicationData, report.ApplicationData); @@ -53,10 +59,9 @@ await sutProvider.GetDependency() .Received(1) .CreateAsync(Arg.Is(r => r.OrganizationId == request.OrganizationId && - r.ReportData == string.Empty && + r.Type == OrganizationReportType.File && r.SummaryData == request.SummaryData && r.ApplicationData == request.ApplicationData && - r.FileId != null && r.FileId.Length == 32 && r.ContentEncryptionKey == "test-encryption-key")); } diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs index 9f973b071b36..db7651b07355 100644 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs @@ -1,5 +1,6 @@ -using AutoFixture; -using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Enums; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Dirt.Reports.Services; using Bit.Core.Dirt.Repositories; @@ -14,30 +15,43 @@ namespace Bit.Core.Test.Dirt.ReportFeatures; [SutProviderCustomize] public class GetOrganizationReportDataV2QueryTests { + private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid organizationId, string fileId) + { + var fileData = new OrganizationReportFileData + { + Id = fileId, + Validated = true + }; + + var report = new OrganizationReport + { + Id = reportId, + OrganizationId = organizationId, + Type = OrganizationReportType.File + }; + report.SetReportFileData(fileData); + return report; + } + [Theory] [BitAutoData] public async Task GetOrganizationReportDataAsync_Success_ReturnsDownloadUrl( SutProvider sutProvider) { // Arrange - var fixture = new Fixture(); var organizationId = Guid.NewGuid(); var reportId = Guid.NewGuid(); var reportFileId = "test-file-id-plaintext"; var expectedUrl = "https://blob.storage.azure.com/sas-url"; - var report = fixture.Build() - .With(r => r.Id, reportId) - .With(r => r.OrganizationId, organizationId) - .With(r => r.FileId, "encrypted-file-id") - .Create(); + var report = CreateReportWithFileData(reportId, organizationId, "encrypted-file-id"); sutProvider.GetDependency() .GetByIdAsync(reportId) .Returns(report); sutProvider.GetDependency() - .GetReportDataDownloadUrlAsync(report, reportFileId) + .GetReportDataDownloadUrlAsync(report, Arg.Any()) .Returns(expectedUrl); // Act @@ -49,7 +63,7 @@ public async Task GetOrganizationReportDataAsync_Success_ReturnsDownloadUrl( await sutProvider.GetDependency() .Received(1) - .GetReportDataDownloadUrlAsync(report, reportFileId); + .GetReportDataDownloadUrlAsync(report, Arg.Any()); } [Theory] @@ -77,16 +91,12 @@ public async Task GetOrganizationReportDataAsync_OrganizationMismatch_ThrowsNotF SutProvider sutProvider) { // Arrange - var fixture = new Fixture(); var organizationId = Guid.NewGuid(); var differentOrgId = Guid.NewGuid(); var reportId = Guid.NewGuid(); var reportFileId = "test-file-id"; - var report = fixture.Build() - .With(r => r.Id, reportId) - .With(r => r.OrganizationId, differentOrgId) - .Create(); + var report = CreateReportWithFileData(reportId, differentOrgId, "file-id"); sutProvider.GetDependency() .GetByIdAsync(reportId) @@ -111,4 +121,31 @@ public async Task GetOrganizationReportDataAsync_MissingReportFileId_ThrowsBadRe await Assert.ThrowsAsync( async () => await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId!)); } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_EmptyReportData_ThrowsNotFoundException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var reportFileId = "test-file-id"; + + var report = new OrganizationReport + { + Id = reportId, + OrganizationId = organizationId, + ReportData = string.Empty, + Type = OrganizationReportType.Data + }; + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(report); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId)); + } } diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs index ba0e55d7576f..1e5dd0576920 100644 --- a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs @@ -1,5 +1,7 @@ using AutoFixture; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Enums; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Repositories; @@ -14,6 +16,24 @@ namespace Bit.Core.Test.Dirt.ReportFeatures; [SutProviderCustomize] public class UpdateOrganizationReportDataV2CommandTests { + private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid organizationId, string fileId) + { + var fileData = new OrganizationReportFileData + { + Id = fileId, + Validated = false + }; + + var report = new OrganizationReport + { + Id = reportId, + OrganizationId = organizationId, + Type = OrganizationReportType.File + }; + report.SetReportFileData(fileData); + return report; + } + [Theory] [BitAutoData] public async Task GetUploadUrlAsync_WithMismatchedFileId_ShouldThrowNotFoundException( @@ -22,11 +42,7 @@ public async Task GetUploadUrlAsync_WithMismatchedFileId_ShouldThrowNotFoundExce // Arrange var fixture = new Fixture(); var request = fixture.Create(); - var existingReport = fixture.Build() - .With(x => x.Id, request.ReportId) - .With(x => x.OrganizationId, request.OrganizationId) - .With(x => x.FileId, "stored-file-id") - .Create(); + var existingReport = CreateReportWithFileData(request.ReportId, request.OrganizationId, "stored-file-id"); sutProvider.GetDependency() .GetByIdAsync(request.ReportId) @@ -67,10 +83,7 @@ public async Task GetUploadUrlAsync_WithMismatchedOrgId_ShouldThrowNotFoundExcep // Arrange var fixture = new Fixture(); var request = fixture.Create(); - var existingReport = fixture.Build() - .With(x => x.Id, request.ReportId) - .With(x => x.OrganizationId, Guid.NewGuid()) // Different org ID - .Create(); + var existingReport = CreateReportWithFileData(request.ReportId, Guid.NewGuid(), "file-id"); sutProvider.GetDependency() .GetByIdAsync(request.ReportId) diff --git a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs index b2a153fffccf..a2243f88c41b 100644 --- a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs @@ -1,8 +1,11 @@ using AutoFixture; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.Services; using Bit.Core.Enums; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; namespace Bit.Core.Test.Dirt.Reports.Services; @@ -10,22 +13,28 @@ namespace Bit.Core.Test.Dirt.Reports.Services; [SutProviderCustomize] public class AzureOrganizationReportStorageServiceTests { - private static Core.Settings.GlobalSettings GetGlobalSettings() + private static AzureOrganizationReportStorageService CreateSut() { var globalSettings = new Core.Settings.GlobalSettings(); globalSettings.OrganizationReport.ConnectionString = "UseDevelopmentStorage=true"; - return globalSettings; + var logger = Substitute.For>(); + return new AzureOrganizationReportStorageService(globalSettings, logger); + } + + private static OrganizationReportFileData CreateFileData(string fileId = "test-file-id-123") + { + return new OrganizationReportFileData + { + Id = fileId, + Validated = false + }; } [Fact] public void FileUploadType_ReturnsAzure() { - // Arrange - var globalSettings = GetGlobalSettings(); - var sut = new AzureOrganizationReportStorageService(globalSettings); - - // Act & Assert - Assert.Equal(FileUploadType.Azure, sut.FileUploadType); + // Arrange & Act & Assert + Assert.Equal(FileUploadType.Azure, CreateSut().FileUploadType); } [Fact] @@ -33,19 +42,19 @@ public async Task GetReportDataUploadUrlAsync_ReturnsValidSasUrl() { // Arrange var fixture = new Fixture(); - var globalSettings = GetGlobalSettings(); - var sut = new AzureOrganizationReportStorageService(globalSettings); + var sut = CreateSut(); var report = fixture.Build() .With(r => r.OrganizationId, Guid.NewGuid()) .With(r => r.Id, Guid.NewGuid()) .With(r => r.CreationDate, new DateTime(2026, 2, 17)) + .With(r => r.ReportData, string.Empty) .Create(); - var reportFileId = "test-file-id-123"; + var fileData = CreateFileData(); // Act - var url = await sut.GetReportDataUploadUrlAsync(report, reportFileId); + var url = await sut.GetReportDataUploadUrlAsync(report, fileData); // Assert Assert.NotNull(url); @@ -61,19 +70,19 @@ public async Task GetReportDataDownloadUrlAsync_ReturnsValidSasUrl() { // Arrange var fixture = new Fixture(); - var globalSettings = GetGlobalSettings(); - var sut = new AzureOrganizationReportStorageService(globalSettings); + var sut = CreateSut(); var report = fixture.Build() .With(r => r.OrganizationId, Guid.NewGuid()) .With(r => r.Id, Guid.NewGuid()) .With(r => r.CreationDate, new DateTime(2026, 2, 17)) + .With(r => r.ReportData, string.Empty) .Create(); - var reportFileId = "test-file-id-123"; + var fileData = CreateFileData(); // Act - var url = await sut.GetReportDataDownloadUrlAsync(report, reportFileId); + var url = await sut.GetReportDataDownloadUrlAsync(report, fileData); // Assert Assert.NotNull(url); @@ -88,26 +97,26 @@ public async Task BlobPath_FormatsCorrectly() { // Arrange var fixture = new Fixture(); - var globalSettings = GetGlobalSettings(); - var sut = new AzureOrganizationReportStorageService(globalSettings); + var sut = CreateSut(); var orgId = Guid.Parse("11111111-1111-1111-1111-111111111111"); var reportId = Guid.Parse("22222222-2222-2222-2222-222222222222"); var creationDate = new DateTime(2026, 2, 17); - var reportFileId = "abc123xyz"; + var fileData = CreateFileData("abc123xyz"); var report = fixture.Build() .With(r => r.OrganizationId, orgId) .With(r => r.Id, reportId) .With(r => r.CreationDate, creationDate) + .With(r => r.ReportData, string.Empty) .Create(); // Act - var url = await sut.GetReportDataUploadUrlAsync(report, reportFileId); + var url = await sut.GetReportDataUploadUrlAsync(report, fileData); // Assert // Expected path: {orgId}/{MM-dd-yyyy}/{reportId}/{fileId}/report-data.json - var expectedPath = $"{orgId}/02-17-2026/{reportId}/{reportFileId}/report-data.json"; + var expectedPath = $"{orgId}/02-17-2026/{reportId}/{fileData.Id}/report-data.json"; Assert.Contains(expectedPath, url); } } diff --git a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs index c97be04046cb..1c7521551d8c 100644 --- a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs @@ -1,5 +1,6 @@ using AutoFixture; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.Services; using Bit.Core.Enums; using Bit.Test.Common.AutoFixture.Attributes; @@ -18,6 +19,15 @@ private static Core.Settings.GlobalSettings GetGlobalSettings() return globalSettings; } + private static OrganizationReportFileData CreateFileData(string fileId = "test-file-id") + { + return new OrganizationReportFileData + { + Id = fileId, + Validated = false + }; + } + [Fact] public void FileUploadType_ReturnsDirect() { @@ -42,12 +52,13 @@ public async Task GetReportDataUploadUrlAsync_ReturnsApiEndpoint() var report = fixture.Build() .With(r => r.OrganizationId, orgId) .With(r => r.Id, reportId) + .With(r => r.ReportData, string.Empty) .Create(); - var reportFileId = "test-file-id"; + var fileData = CreateFileData(); // Act - var url = await sut.GetReportDataUploadUrlAsync(report, reportFileId); + var url = await sut.GetReportDataUploadUrlAsync(report, fileData); // Assert Assert.Equal($"/reports/v2/organizations/{orgId}/{reportId}/file/report-data", url); @@ -64,23 +75,24 @@ public async Task GetReportDataDownloadUrlAsync_ReturnsBaseUrlWithPath() var orgId = Guid.Parse("11111111-1111-1111-1111-111111111111"); var reportId = Guid.Parse("22222222-2222-2222-2222-222222222222"); var creationDate = new DateTime(2026, 2, 17); - var reportFileId = "abc123"; + var fileData = CreateFileData("abc123"); var report = fixture.Build() .With(r => r.OrganizationId, orgId) .With(r => r.Id, reportId) .With(r => r.CreationDate, creationDate) + .With(r => r.ReportData, string.Empty) .Create(); // Act - var url = await sut.GetReportDataDownloadUrlAsync(report, reportFileId); + var url = await sut.GetReportDataDownloadUrlAsync(report, fileData); // Assert Assert.StartsWith("https://localhost/reports/", url); Assert.Contains($"{orgId}", url); Assert.Contains("02-17-2026", url); // Date format Assert.Contains($"{reportId}", url); - Assert.Contains(reportFileId, url); + Assert.Contains(fileData.Id, url); Assert.EndsWith("report-data.json", url); } @@ -90,7 +102,7 @@ public async Task GetReportDataDownloadUrlAsync_ReturnsBaseUrlWithPath() public async Task UploadReportDataAsync_WithPathTraversalPayload_WritesOutsideBaseDirectory(string maliciousFileId) { // Arrange - demonstrates the path traversal vulnerability that is mitigated - // by validating reportFileId matches report.FileId at the controller/command layer + // by validating reportFileId matches report's file data at the controller/command layer var fixture = new Fixture(); var tempDir = Path.Combine(Path.GetTempPath(), "bitwarden-test-" + Guid.NewGuid()); @@ -104,15 +116,22 @@ public async Task UploadReportDataAsync_WithPathTraversalPayload_WritesOutsideBa .With(r => r.OrganizationId, Guid.NewGuid()) .With(r => r.Id, Guid.NewGuid()) .With(r => r.CreationDate, DateTime.UtcNow) + .With(r => r.ReportData, string.Empty) .Create(); + var maliciousFileData = new OrganizationReportFileData + { + Id = maliciousFileId, + Validated = false + }; + var testData = "malicious content"; var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(testData)); try { // Act - await sut.UploadReportDataAsync(report, maliciousFileId, stream); + await sut.UploadReportDataAsync(report, maliciousFileData, stream); // Assert - the file is written at a path that escapes the intended report directory var intendedBaseDir = Path.Combine(tempDir, report.OrganizationId.ToString(), @@ -150,20 +169,21 @@ public async Task UploadReportDataAsync_CreatesDirectoryAndWritesFile() .With(r => r.OrganizationId, Guid.NewGuid()) .With(r => r.Id, Guid.NewGuid()) .With(r => r.CreationDate, DateTime.UtcNow) + .With(r => r.ReportData, string.Empty) .Create(); - var reportFileId = "test-file-123"; + var fileData = CreateFileData("test-file-123"); var testData = "test report data content"; var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(testData)); try { // Act - await sut.UploadReportDataAsync(report, reportFileId, stream); + await sut.UploadReportDataAsync(report, fileData, stream); // Assert var expectedDir = Path.Combine(tempDir, report.OrganizationId.ToString(), - report.CreationDate.ToString("MM-dd-yyyy"), report.Id.ToString(), reportFileId); + report.CreationDate.ToString("MM-dd-yyyy"), report.Id.ToString(), fileData.Id); Assert.True(Directory.Exists(expectedDir)); var expectedFile = Path.Combine(expectedDir, "report-data.json"); @@ -181,4 +201,79 @@ public async Task UploadReportDataAsync_CreatesDirectoryAndWritesFile() } } } + + [Fact] + public async Task ValidateFileAsync_FileExists_ReturnsValidAndLength() + { + // Arrange + var fixture = new Fixture(); + var tempDir = Path.Combine(Path.GetTempPath(), "bitwarden-test-" + Guid.NewGuid()); + + var globalSettings = new Core.Settings.GlobalSettings(); + globalSettings.OrganizationReport.BaseDirectory = tempDir; + globalSettings.OrganizationReport.BaseUrl = "https://localhost/reports"; + + var sut = new LocalOrganizationReportStorageService(globalSettings); + + var report = fixture.Build() + .With(r => r.OrganizationId, Guid.NewGuid()) + .With(r => r.Id, Guid.NewGuid()) + .With(r => r.CreationDate, DateTime.UtcNow) + .With(r => r.ReportData, string.Empty) + .Create(); + + var fileData = CreateFileData("validate-test-file"); + var testData = "test content for validation"; + var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(testData)); + + try + { + // First upload a file + await sut.UploadReportDataAsync(report, fileData, stream); + + // Act + var (valid, length) = await sut.ValidateFileAsync(report, fileData, 0, 1000); + + // Assert + Assert.True(valid); + Assert.Equal(testData.Length, length); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task ValidateFileAsync_FileDoesNotExist_ReturnsInvalid() + { + // Arrange + var fixture = new Fixture(); + var tempDir = Path.Combine(Path.GetTempPath(), "bitwarden-test-" + Guid.NewGuid()); + + var globalSettings = new Core.Settings.GlobalSettings(); + globalSettings.OrganizationReport.BaseDirectory = tempDir; + globalSettings.OrganizationReport.BaseUrl = "https://localhost/reports"; + + var sut = new LocalOrganizationReportStorageService(globalSettings); + + var report = fixture.Build() + .With(r => r.OrganizationId, Guid.NewGuid()) + .With(r => r.Id, Guid.NewGuid()) + .With(r => r.CreationDate, DateTime.UtcNow) + .With(r => r.ReportData, string.Empty) + .Create(); + + var fileData = CreateFileData("nonexistent-file"); + + // Act + var (valid, length) = await sut.ValidateFileAsync(report, fileData, 0, 1000); + + // Assert + Assert.False(valid); + Assert.Equal(-1, length); + } } From f5947e2f25dbd2f07dd7f7b1a7ca30b42c0ca796 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Wed, 4 Mar 2026 09:43:50 -0600 Subject: [PATCH 03/85] 31923 updating code to now use the ReportFile field --- .../OrganizationReportsController.cs | 245 ++++++++++++++++-- .../OrganizationReportsV2Controller.cs | 191 -------------- .../OrganizationReportResponseModel.cs | 2 + .../OrganizationReportV2ResponseModel.cs | 5 +- src/Core/Constants.cs | 1 + src/Core/Dirt/Entities/OrganizationReport.cs | 9 +- src/Core/Dirt/Enums/OrganizationReportType.cs | 7 - .../Models/Data/OrganizationReportFileData.cs | 20 -- src/Core/Dirt/Models/Data/ReportFile.cs | 32 +++ .../CreateOrganizationReportV2Command.cs | 5 +- .../IValidateOrganizationReportFileCommand.cs | 8 + .../ReportingServiceCollectionExtensions.cs | 1 + .../ValidateOrganizationReportFileCommand.cs | 51 ++++ .../AzureOrganizationReportStorageService.cs | 28 +- .../IOrganizationReportStorageService.cs | 10 +- .../LocalOrganizationReportStorageService.cs | 10 +- .../NoopOrganizationReportStorageService.cs | 10 +- .../OrganizationReportResponseModelTests.cs | 35 +++ .../OrganizationReportsControllerTests.cs | 10 +- .../CreateOrganizationReportV2CommandTests.cs | 6 +- .../GetOrganizationReportDataV2QueryTests.cs | 14 +- ...ateOrganizationReportDataV2CommandTests.cs | 7 +- ...idateOrganizationReportFileCommandTests.cs | 154 +++++++++++ ...reOrganizationReportStorageServiceTests.cs | 17 +- ...alOrganizationReportStorageServiceTests.cs | 10 +- 25 files changed, 588 insertions(+), 300 deletions(-) delete mode 100644 src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs delete mode 100644 src/Core/Dirt/Enums/OrganizationReportType.cs delete mode 100644 src/Core/Dirt/Models/Data/OrganizationReportFileData.cs create mode 100644 src/Core/Dirt/Models/Data/ReportFile.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IValidateOrganizationReportFileCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs create mode 100644 test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index fc9a1b2d84a0..65e702c64655 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -1,8 +1,15 @@ -using Bit.Api.Dirt.Models.Response; +using System.Text.Json; +using Bit.Api.Dirt.Models.Response; +using Bit.Api.Utilities; +using Bit.Core; using Bit.Core.Context; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -23,6 +30,15 @@ public class OrganizationReportsController : Controller private readonly IUpdateOrganizationReportDataCommand _updateOrganizationReportDataCommand; private readonly IGetOrganizationReportApplicationDataQuery _getOrganizationReportApplicationDataQuery; private readonly IUpdateOrganizationReportApplicationDataCommand _updateOrganizationReportApplicationDataCommand; + private readonly IFeatureService _featureService; + private readonly IApplicationCacheService _applicationCacheService; + private readonly IOrganizationReportStorageService _storageService; + private readonly ICreateOrganizationReportV2Command _createV2Command; + private readonly IUpdateOrganizationReportDataV2Command _updateDataV2Command; + private readonly IGetOrganizationReportDataV2Query _getDataV2Query; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IValidateOrganizationReportFileCommand _validateCommand; + private readonly ILogger _logger; public OrganizationReportsController( ICurrentContext currentContext, @@ -35,8 +51,16 @@ public OrganizationReportsController( IGetOrganizationReportDataQuery getOrganizationReportDataQuery, IUpdateOrganizationReportDataCommand updateOrganizationReportDataCommand, IGetOrganizationReportApplicationDataQuery getOrganizationReportApplicationDataQuery, - IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicationDataCommand - ) + IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicationDataCommand, + IFeatureService featureService, + IApplicationCacheService applicationCacheService, + IOrganizationReportStorageService storageService, + ICreateOrganizationReportV2Command createV2Command, + IUpdateOrganizationReportDataV2Command updateDataV2Command, + IGetOrganizationReportDataV2Query getDataV2Query, + IOrganizationReportRepository organizationReportRepo, + IValidateOrganizationReportFileCommand validateCommand, + ILogger logger) { _currentContext = currentContext; _getOrganizationReportQuery = getOrganizationReportQuery; @@ -49,10 +73,17 @@ IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicat _updateOrganizationReportDataCommand = updateOrganizationReportDataCommand; _getOrganizationReportApplicationDataQuery = getOrganizationReportApplicationDataQuery; _updateOrganizationReportApplicationDataCommand = updateOrganizationReportApplicationDataCommand; + _featureService = featureService; + _applicationCacheService = applicationCacheService; + _storageService = storageService; + _createV2Command = createV2Command; + _updateDataV2Command = updateDataV2Command; + _getDataV2Query = getDataV2Query; + _organizationReportRepo = organizationReportRepo; + _validateCommand = validateCommand; + _logger = logger; } - #region Whole OrganizationReport Endpoints - [HttpGet("{organizationId}/latest")] public async Task GetLatestOrganizationReportAsync(Guid organizationId) { @@ -70,29 +101,70 @@ public async Task GetLatestOrganizationReportAsync(Guid organizat [HttpGet("{organizationId}/{reportId}")] public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) { + if (_featureService.IsEnabled(FeatureFlagKeys.WholeReportDataFileStorage)) + { + await AuthorizeV2Async(organizationId); + + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + + if (report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } + + return Ok(new OrganizationReportResponseModel(report)); + } + if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); } - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + var v1Report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - if (report == null) + if (v1Report == null) { throw new NotFoundException("Report not found for the specified organization."); } - if (report.OrganizationId != organizationId) + if (v1Report.OrganizationId != organizationId) { throw new BadRequestException("Invalid report ID"); } - return Ok(report); + return Ok(v1Report); } [HttpPost("{organizationId}")] - public async Task CreateOrganizationReportAsync(Guid organizationId, [FromBody] AddOrganizationReportRequest request) + public async Task CreateOrganizationReportAsync( + Guid organizationId, + [FromBody] AddOrganizationReportRequest request) { + if (_featureService.IsEnabled(FeatureFlagKeys.WholeReportDataFileStorage)) + { + if (organizationId == Guid.Empty) + { + throw new BadRequestException("Organization ID is required."); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + await AuthorizeV2Async(organizationId); + + var report = await _createV2Command.CreateAsync(request); + var fileData = report.GetReportFileData()!; + + return Ok(new OrganizationReportV2ResponseModel + { + ReportDataUploadUrl = await _storageService.GetReportDataUploadUrlAsync(report, fileData), + ReportResponse = new OrganizationReportResponseModel(report), + FileUploadType = _storageService.FileUploadType + }); + } + if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); @@ -103,8 +175,8 @@ public async Task CreateOrganizationReportAsync(Guid organization throw new BadRequestException("Organization ID in the request body must match the route parameter"); } - var report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request); - var response = report == null ? null : new OrganizationReportResponseModel(report); + var v1Report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request); + var response = v1Report == null ? null : new OrganizationReportResponseModel(v1Report); return Ok(response); } @@ -126,10 +198,6 @@ public async Task UpdateOrganizationReportAsync(Guid organization return Ok(response); } - #endregion - - # region SummaryData Field Endpoints - [HttpGet("{organizationId}/data/summary")] public async Task GetOrganizationReportSummaryDataByDateRangeAsync( Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) @@ -191,9 +259,6 @@ public async Task UpdateOrganizationReportSummaryAsync(Guid organ return Ok(response); } - #endregion - - #region ReportData Field Endpoints [HttpGet("{organizationId}/data/report/{reportId}")] public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) @@ -214,8 +279,37 @@ public async Task GetOrganizationReportDataAsync(Guid organizatio } [HttpPatch("{organizationId}/data/report/{reportId}")] - public async Task UpdateOrganizationReportDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportDataRequest request) + public async Task UpdateOrganizationReportDataAsync( + Guid organizationId, + Guid reportId, + [FromBody] UpdateOrganizationReportDataRequest request, + [FromQuery] string? reportFileId) { + if (_featureService.IsEnabled(FeatureFlagKeys.WholeReportDataFileStorage)) + { + if (request.OrganizationId != organizationId || request.ReportId != reportId) + { + throw new BadRequestException("Organization ID and Report ID must match route parameters"); + } + + if (string.IsNullOrEmpty(reportFileId)) + { + throw new BadRequestException("ReportFileId query parameter is required"); + } + + await AuthorizeV2Async(organizationId); + + var uploadUrl = await _updateDataV2Command.GetUploadUrlAsync(request, reportFileId); + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + + return Ok(new OrganizationReportV2ResponseModel + { + ReportDataUploadUrl = uploadUrl, + ReportResponse = new OrganizationReportResponseModel(report), + FileUploadType = _storageService.FileUploadType + }); + } + if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); @@ -237,10 +331,6 @@ public async Task UpdateOrganizationReportDataAsync(Guid organiza return Ok(response); } - #endregion - - #region ApplicationData Field Endpoints - [HttpGet("{organizationId}/data/application/{reportId}")] public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) { @@ -297,5 +387,110 @@ public async Task UpdateOrganizationReportApplicationDataAsync(Gu } } - #endregion + [RequireFeature(FeatureFlagKeys.WholeReportDataFileStorage)] + [HttpPost("{organizationId}/{reportId}/file/report-data")] + [SelfHosted(SelfHostedOnly = true)] + [RequestSizeLimit(Constants.FileSize501mb)] + [DisableFormValueModelBinding] + public async Task UploadReportDataAsync(Guid organizationId, Guid reportId, [FromQuery] string reportFileId) + { + await AuthorizeV2Async(organizationId); + + if (!Request?.ContentType?.Contains("multipart/") ?? true) + { + throw new BadRequestException("Invalid contenwt."); + } + + if (string.IsNullOrEmpty(reportFileId)) + { + throw new BadRequestException("ReportFileId query parameter is required"); + } + + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + if (report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } + + var fileData = report.GetReportFileData(); + if (fileData == null || fileData.Id != reportFileId) + { + throw new NotFoundException(); + } + + await Request.GetFileAsync(async (stream) => + { + await _storageService.UploadReportDataAsync(report, fileData, stream); + }); + + var (valid, length) = await _storageService.ValidateFileAsync(report, fileData, 0, Constants.FileSize501mb); + if (!valid) + { + throw new BadRequestException("File received does not match expected constraints."); + } + + fileData.Validated = true; + fileData.Size = length; + report.SetReportFileData(fileData); + report.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(report); + } + + [AllowAnonymous] + [RequireFeature(FeatureFlagKeys.WholeReportDataFileStorage)] + [HttpPost("file/validate/azure")] + public async Task AzureValidateFile() + { + return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> + { + { + "Microsoft.Storage.BlobCreated", async (eventGridEvent) => + { + try + { + var blobName = + eventGridEvent.Subject.Split($"{AzureOrganizationReportStorageService.ContainerName}/blobs/")[1]; + var reportId = AzureOrganizationReportStorageService.ReportIdFromBlobName(blobName); + var report = await _organizationReportRepo.GetByIdAsync(new Guid(reportId)); + if (report == null) + { + if (_storageService is AzureOrganizationReportStorageService azureStorageService) + { + await azureStorageService.DeleteBlobAsync(blobName); + } + + return; + } + + var fileData = report.GetReportFileData(); + if (fileData == null) + { + return; + } + + await _validateCommand.ValidateAsync(report, fileData.Id!); + } + catch (Exception e) + { + _logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", + JsonSerializer.Serialize(eventGridEvent)); + } + } + } + }); + } + + private async Task AuthorizeV2Async(Guid organizationId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + if (orgAbility is null || !orgAbility.UseRiskInsights) + { + throw new BadRequestException("Your organization's plan does not support this feature."); + } + } } diff --git a/src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs b/src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs deleted file mode 100644 index 13e76734b0c3..000000000000 --- a/src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs +++ /dev/null @@ -1,191 +0,0 @@ -using Bit.Api.Dirt.Models.Response; -using Bit.Api.Utilities; -using Bit.Core; -using Bit.Core.Context; -using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -using Bit.Core.Dirt.Reports.Services; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Bit.Core.Services; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Dirt.Controllers; - -[Route("reports/v2/organizations")] -[Authorize("Application")] -public class OrganizationReportsV2Controller : Controller -{ - private readonly ICurrentContext _currentContext; - private readonly IApplicationCacheService _applicationCacheService; - private readonly IOrganizationReportStorageService _storageService; - private readonly ICreateOrganizationReportV2Command _createCommand; - private readonly IUpdateOrganizationReportDataV2Command _updateDataCommand; - private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; - private readonly IGetOrganizationReportDataV2Query _getDataQuery; - private readonly IUpdateOrganizationReportCommand _updateOrganizationReportCommand; - private readonly IOrganizationReportRepository _organizationReportRepo; - - public OrganizationReportsV2Controller( - ICurrentContext currentContext, - IApplicationCacheService applicationCacheService, - IOrganizationReportStorageService storageService, - ICreateOrganizationReportV2Command createCommand, - IUpdateOrganizationReportDataV2Command updateDataCommand, - IGetOrganizationReportQuery getOrganizationReportQuery, - IGetOrganizationReportDataV2Query getDataQuery, - IUpdateOrganizationReportCommand updateOrganizationReportCommand, - IOrganizationReportRepository organizationReportRepo) - { - _currentContext = currentContext; - _applicationCacheService = applicationCacheService; - _storageService = storageService; - _createCommand = createCommand; - _updateDataCommand = updateDataCommand; - _getOrganizationReportQuery = getOrganizationReportQuery; - _getDataQuery = getDataQuery; - _updateOrganizationReportCommand = updateOrganizationReportCommand; - _organizationReportRepo = organizationReportRepo; - } - - private async Task AuthorizeAsync(Guid organizationId) - { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } - - var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); - if (orgAbility is null || !orgAbility.UseRiskInsights) - { - throw new BadRequestException("Your organization's plan does not support this feature."); - } - } - - [HttpPost("{organizationId}")] - public async Task CreateOrganizationReportAsync( - Guid organizationId, - [FromBody] AddOrganizationReportRequest request) - { - if (organizationId == Guid.Empty) - { - throw new BadRequestException("Organization ID is required."); - } - - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - - await AuthorizeAsync(organizationId); - - var report = await _createCommand.CreateAsync(request); - - var fileData = report.GetReportFileData()!; - - return new OrganizationReportV2ResponseModel - { - ReportDataUploadUrl = await _storageService.GetReportDataUploadUrlAsync(report, fileData), - ReportResponse = new OrganizationReportResponseModel(report), - FileUploadType = _storageService.FileUploadType - }; - } - - [HttpGet("{organizationId}/{reportId}")] - public async Task GetOrganizationReportAsync( - Guid organizationId, - Guid reportId) - { - await AuthorizeAsync(organizationId); - - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - - if (report.OrganizationId != organizationId) - { - throw new BadRequestException("Invalid report ID"); - } - - return new OrganizationReportResponseModel(report); - } - - [HttpPatch("{organizationId}/data/report/{reportId}")] - public async Task GetReportDataUploadUrlAsync( - Guid organizationId, - Guid reportId, - [FromBody] UpdateOrganizationReportDataRequest request, - [FromQuery] string reportFileId) - { - if (request.OrganizationId != organizationId || request.ReportId != reportId) - { - throw new BadRequestException("Organization ID and Report ID must match route parameters"); - } - - if (string.IsNullOrEmpty(reportFileId)) - { - throw new BadRequestException("ReportFileId query parameter is required"); - } - - await AuthorizeAsync(organizationId); - - var uploadUrl = await _updateDataCommand.GetUploadUrlAsync(request, reportFileId); - - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - - return new OrganizationReportV2ResponseModel - { - ReportDataUploadUrl = uploadUrl, - ReportResponse = new OrganizationReportResponseModel(report), - FileUploadType = _storageService.FileUploadType - }; - } - - [HttpPost("{organizationId}/{reportId}/file/report-data")] - [SelfHosted(SelfHostedOnly = true)] - [RequestSizeLimit(Constants.FileSize501mb)] - [DisableFormValueModelBinding] - public async Task UploadReportDataAsync(Guid organizationId, Guid reportId, [FromQuery] string reportFileId) - { - await AuthorizeAsync(organizationId); - - if (!Request?.ContentType?.Contains("multipart/") ?? true) - { - throw new BadRequestException("Invalid content."); - } - - if (string.IsNullOrEmpty(reportFileId)) - { - throw new BadRequestException("ReportFileId query parameter is required"); - } - - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - if (report.OrganizationId != organizationId) - { - throw new BadRequestException("Invalid report ID"); - } - - var fileData = report.GetReportFileData(); - if (fileData == null || fileData.Id != reportFileId) - { - throw new NotFoundException(); - } - - await Request.GetFileAsync(async (stream) => - { - await _storageService.UploadReportDataAsync(report, fileData, stream); - }); - - var (valid, length) = await _storageService.ValidateFileAsync(report, fileData, 0, Constants.FileSize501mb); - if (!valid) - { - throw new BadRequestException("File received does not match expected constraints."); - } - - fileData.Validated = true; - fileData.Size = length; - report.SetReportFileData(fileData); - report.RevisionDate = DateTime.UtcNow; - await _organizationReportRepo.ReplaceAsync(report); - } -} diff --git a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs index e477e5b806a7..c210303c5b2e 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs @@ -1,4 +1,5 @@ using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; namespace Bit.Api.Dirt.Models.Response; @@ -13,6 +14,7 @@ public class OrganizationReportResponseModel public int? PasswordCount { get; set; } public int? PasswordAtRiskCount { get; set; } public int? MemberCount { get; set; } + public ReportFile? File { get; set; } public DateTime? CreationDate { get; set; } = null; public DateTime? RevisionDate { get; set; } = null; diff --git a/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs index afadcd2f8db9..63f73d07b4b8 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs @@ -1,11 +1,10 @@ using Bit.Core.Enums; -using Bit.Core.Models.Api; namespace Bit.Api.Dirt.Models.Response; -public class OrganizationReportV2ResponseModel : ResponseModel +public class OrganizationReportV2ResponseModel { - public OrganizationReportV2ResponseModel() : base("organizationReport-v2") { } + public OrganizationReportV2ResponseModel() { } public string ReportDataUploadUrl { get; set; } = string.Empty; public OrganizationReportResponseModel ReportResponse { get; set; } = null!; diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 2eef68cb4243..b07271cea132 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -268,6 +268,7 @@ public static class FeatureFlagKeys public const string ArchiveVaultItems = "pm-19148-innovation-archive"; /* DIRT Team */ + public const string WholeReportDataFileStorage = "pm-31920-whole-report-data-file-storage"; public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging"; public const string EventManagementForHuntress = "event-management-for-huntress"; diff --git a/src/Core/Dirt/Entities/OrganizationReport.cs b/src/Core/Dirt/Entities/OrganizationReport.cs index 81c9dd6e500a..d20b5772c66e 100644 --- a/src/Core/Dirt/Entities/OrganizationReport.cs +++ b/src/Core/Dirt/Entities/OrganizationReport.cs @@ -1,7 +1,6 @@ #nullable enable using System.Text.Json; -using Bit.Core.Dirt.Enums; using Bit.Core.Dirt.Models.Data; using Bit.Core.Entities; using Bit.Core.Utilities; @@ -30,19 +29,17 @@ public class OrganizationReport : ITableObject public int? PasswordAtRiskCount { get; set; } public int? CriticalPasswordCount { get; set; } public int? CriticalPasswordAtRiskCount { get; set; } - public OrganizationReportType Type { get; set; } - - public OrganizationReportFileData? GetReportFileData() + public ReportFile? GetReportFileData() { if (string.IsNullOrWhiteSpace(ReportData)) { return null; } - return JsonSerializer.Deserialize(ReportData); + return JsonSerializer.Deserialize(ReportData); } - public void SetReportFileData(OrganizationReportFileData data) + public void SetReportFileData(ReportFile data) { ReportData = JsonSerializer.Serialize(data, JsonHelpers.IgnoreWritingNull); } diff --git a/src/Core/Dirt/Enums/OrganizationReportType.cs b/src/Core/Dirt/Enums/OrganizationReportType.cs deleted file mode 100644 index ea6317180524..000000000000 --- a/src/Core/Dirt/Enums/OrganizationReportType.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bit.Core.Dirt.Enums; - -public enum OrganizationReportType : byte -{ - Data = 0, - File = 1 -} diff --git a/src/Core/Dirt/Models/Data/OrganizationReportFileData.cs b/src/Core/Dirt/Models/Data/OrganizationReportFileData.cs deleted file mode 100644 index 78c651867d45..000000000000 --- a/src/Core/Dirt/Models/Data/OrganizationReportFileData.cs +++ /dev/null @@ -1,20 +0,0 @@ -#nullable enable - -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; -using static System.Text.Json.Serialization.JsonNumberHandling; - -namespace Bit.Core.Dirt.Models.Data; - -public class OrganizationReportFileData -{ - [JsonNumberHandling(WriteAsString | AllowReadingFromString)] - public long Size { get; set; } - - [DisallowNull] - public string? Id { get; set; } - - public string FileName { get; set; } = "report-data.json"; - - public bool Validated { get; set; } -} diff --git a/src/Core/Dirt/Models/Data/ReportFile.cs b/src/Core/Dirt/Models/Data/ReportFile.cs new file mode 100644 index 000000000000..e9106657d3cc --- /dev/null +++ b/src/Core/Dirt/Models/Data/ReportFile.cs @@ -0,0 +1,32 @@ +#nullable enable + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using static System.Text.Json.Serialization.JsonNumberHandling; + +namespace Bit.Core.Dirt.Models.Data; + +/// +/// Metadata about a file-backed organization report stored in blob storage. +/// Serialized into . +/// +public class ReportFile +{ + /// Validated byte-length of the blob (set after upload validation). + [JsonNumberHandling(WriteAsString | AllowReadingFromString)] + public long Size { get; set; } + + /// Random token that forms part of the blob path. + [DisallowNull] + public string? Id { get; set; } + + /// Leaf file name inside the blob path. + public string FileName { get; set; } = string.Empty; + + /// + /// Whether the blob has been validated after upload. + /// Cloud uploads start false and are set to true by the Event Grid webhook. + /// Self-hosted uploads are validated inline and default to true. + /// + public bool Validated { get; set; } = true; +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs index 54ce4070f2d8..135e4b09dab6 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs @@ -1,5 +1,4 @@ using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Enums; using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; @@ -41,16 +40,16 @@ public async Task CreateAsync(AddOrganizationReportRequest r throw new BadRequestException(errorMessage); } - var fileData = new OrganizationReportFileData + var fileData = new ReportFile { Id = CoreHelpers.SecureRandomString(32, upper: false, special: false), + FileName = "report-data.json", Validated = false }; var organizationReport = new OrganizationReport { OrganizationId = request.OrganizationId, - Type = OrganizationReportType.File, CreationDate = DateTime.UtcNow, ContentEncryptionKey = request.ContentEncryptionKey ?? string.Empty, SummaryData = request.SummaryData, diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IValidateOrganizationReportFileCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IValidateOrganizationReportFileCommand.cs new file mode 100644 index 000000000000..bba11a205966 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IValidateOrganizationReportFileCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Entities; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IValidateOrganizationReportFileCommand +{ + Task ValidateAsync(OrganizationReport report, string reportFileId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index 0331d2ffff8c..4471ea8c3c01 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -27,6 +27,7 @@ public static void AddReportingServices(this IServiceCollection services) // v2 file storage commands services.AddScoped(); services.AddScoped(); + services.AddScoped(); // v2 file storage queries services.AddScoped(); diff --git a/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs new file mode 100644 index 000000000000..a78c6880608d --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs @@ -0,0 +1,51 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class ValidateOrganizationReportFileCommand : IValidateOrganizationReportFileCommand +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IOrganizationReportStorageService _storageService; + private readonly ILogger _logger; + + public ValidateOrganizationReportFileCommand( + IOrganizationReportRepository organizationReportRepo, + IOrganizationReportStorageService storageService, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _storageService = storageService; + _logger = logger; + } + + public async Task ValidateAsync(OrganizationReport report, string reportFileId) + { + var fileData = report.GetReportFileData(); + if (fileData == null || fileData.Id != reportFileId) + { + return false; + } + + var (valid, length) = await _storageService.ValidateFileAsync(report, fileData, 0, Constants.FileSize501mb); + if (!valid) + { + _logger.LogWarning( + "Deleted report {ReportId} because its file size {Size} was invalid.", + report.Id, length); + await _storageService.DeleteReportFilesAsync(report, reportFileId); + await _organizationReportRepo.DeleteAsync(report); + return false; + } + + fileData.Validated = true; + fileData.Size = length; + report.SetReportFileData(fileData); + report.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(report); + return true; + } +} diff --git a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs index 8698c87087e0..4580b7c1fbd1 100644 --- a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs @@ -20,6 +20,8 @@ public class AzureOrganizationReportStorageService : IOrganizationReportStorageS public FileUploadType FileUploadType => FileUploadType.Azure; + public static string ReportIdFromBlobName(string blobName) => blobName.Split('/')[2]; + public AzureOrganizationReportStorageService( GlobalSettings globalSettings, ILogger logger) @@ -28,7 +30,7 @@ public AzureOrganizationReportStorageService( _logger = logger; } - public async Task GetReportDataUploadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) + public async Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData) { await InitAsync(); var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); @@ -37,7 +39,7 @@ public async Task GetReportDataUploadUrlAsync(OrganizationReport report, DateTime.UtcNow.Add(_sasTokenLifetime)).ToString(); } - public async Task GetReportDataDownloadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) + public async Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData) { await InitAsync(); var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); @@ -45,7 +47,7 @@ public async Task GetReportDataDownloadUrlAsync(OrganizationReport repor DateTime.UtcNow.Add(_sasTokenLifetime)).ToString(); } - public async Task UploadReportDataAsync(OrganizationReport report, OrganizationReportFileData fileData, Stream stream) + public async Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream) { await InitAsync(); var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); @@ -53,7 +55,7 @@ public async Task UploadReportDataAsync(OrganizationReport report, OrganizationR } public async Task<(bool valid, long length)> ValidateFileAsync( - OrganizationReport report, OrganizationReportFileData fileData, long minimum, long maximum) + OrganizationReport report, ReportFile fileData, long minimum, long maximum) { await InitAsync(); @@ -84,6 +86,24 @@ public async Task UploadReportDataAsync(OrganizationReport report, OrganizationR } } + public async Task DeleteBlobAsync(string blobName) + { + await InitAsync(); + var blobClient = _containerClient!.GetBlobClient(blobName); + await blobClient.DeleteIfExistsAsync(); + } + + public async Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId) + { + await InitAsync(); + var prefix = $"{report.OrganizationId}/{report.CreationDate:MM-dd-yyyy}/{report.Id}/{reportFileId}/"; + await foreach (var blobItem in _containerClient!.GetBlobsAsync(prefix: prefix)) + { + var blobClient = _containerClient.GetBlobClient(blobItem.Name); + await blobClient.DeleteIfExistsAsync(); + } + } + private static string BlobPath(OrganizationReport report, string fileId, string fileName) { var date = report.CreationDate.ToString("MM-dd-yyyy"); diff --git a/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs index 948239685a68..bae2eb793aee 100644 --- a/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs @@ -8,11 +8,13 @@ public interface IOrganizationReportStorageService { FileUploadType FileUploadType { get; } - Task GetReportDataUploadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData); + Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData); - Task GetReportDataDownloadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData); + Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData); - Task UploadReportDataAsync(OrganizationReport report, OrganizationReportFileData fileData, Stream stream); + Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream); - Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, OrganizationReportFileData fileData, long minimum, long maximum); + Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, ReportFile fileData, long minimum, long maximum); + + Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId); } diff --git a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs index 0c827da35521..98a07d86006d 100644 --- a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs @@ -18,20 +18,20 @@ public LocalOrganizationReportStorageService(GlobalSettings globalSettings) _baseUrl = globalSettings.OrganizationReport.BaseUrl; } - public Task GetReportDataUploadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) - => Task.FromResult($"/reports/v2/organizations/{report.OrganizationId}/{report.Id}/file/report-data"); + public Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData) + => Task.FromResult($"/reports/organizations/{report.OrganizationId}/{report.Id}/file/report-data"); - public Task GetReportDataDownloadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) + public Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData) { InitDir(); return Task.FromResult($"{_baseUrl}/{RelativePath(report, fileData.Id!, fileData.FileName)}"); } - public async Task UploadReportDataAsync(OrganizationReport report, OrganizationReportFileData fileData, Stream stream) + public async Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream) => await WriteFileAsync(report, fileData.Id!, fileData.FileName, stream); public Task<(bool valid, long length)> ValidateFileAsync( - OrganizationReport report, OrganizationReportFileData fileData, long minimum, long maximum) + OrganizationReport report, ReportFile fileData, long minimum, long maximum) { var path = Path.Combine(_baseDirPath, RelativePath(report, fileData.Id!, fileData.FileName)); if (!File.Exists(path)) diff --git a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs index 69726afdb063..18e0a363e01f 100644 --- a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs @@ -8,11 +8,13 @@ public class NoopOrganizationReportStorageService : IOrganizationReportStorageSe { public FileUploadType FileUploadType => FileUploadType.Direct; - public Task GetReportDataUploadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) => Task.FromResult(string.Empty); + public Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult(string.Empty); - public Task GetReportDataDownloadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) => Task.FromResult(string.Empty); + public Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult(string.Empty); - public Task UploadReportDataAsync(OrganizationReport report, OrganizationReportFileData fileData, Stream stream) => Task.CompletedTask; + public Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream) => Task.CompletedTask; - public Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, OrganizationReportFileData fileData, long minimum, long maximum) => Task.FromResult((true, 0L)); + public Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, ReportFile fileData, long minimum, long maximum) => Task.FromResult((true, 0L)); + + public Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId) => Task.CompletedTask; } diff --git a/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs new file mode 100644 index 000000000000..2de0a6d0c99b --- /dev/null +++ b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs @@ -0,0 +1,35 @@ +using Bit.Api.Dirt.Models.Response; +using Bit.Core.Dirt.Entities; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.Test.Dirt.Models.Response; + +public class OrganizationReportResponseModelTests +{ + [Theory, BitAutoData] + public void Constructor_MapsPropertiesFromEntity(OrganizationReport report) + { + var model = new OrganizationReportResponseModel(report); + + Assert.Equal(report.Id, model.Id); + Assert.Equal(report.OrganizationId, model.OrganizationId); + Assert.Equal(report.ReportData, model.ReportData); + Assert.Equal(report.ContentEncryptionKey, model.ContentEncryptionKey); + Assert.Equal(report.SummaryData, model.SummaryData); + Assert.Equal(report.ApplicationData, model.ApplicationData); + Assert.Equal(report.PasswordCount, model.PasswordCount); + Assert.Equal(report.PasswordAtRiskCount, model.PasswordAtRiskCount); + Assert.Equal(report.MemberCount, model.MemberCount); + Assert.Equal(report.CreationDate, model.CreationDate); + Assert.Equal(report.RevisionDate, model.RevisionDate); + } + + [Theory, BitAutoData] + public void Constructor_FileIsNull(OrganizationReport report) + { + var model = new OrganizationReportResponseModel(report); + + Assert.Null(model.File); + } +} diff --git a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs index 880be1e4d9d1..cf8c233179ba 100644 --- a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs +++ b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs @@ -813,7 +813,7 @@ public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ReturnsOkRe .Returns(expectedReport); // Act - var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); + var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null); // Assert var okResult = Assert.IsType(result); @@ -835,7 +835,7 @@ public async Task UpdateOrganizationReportDataAsync_WithoutAccess_ThrowsNotFound // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null)); // Verify that the command was not called await sutProvider.GetDependency() @@ -860,7 +860,7 @@ public async Task UpdateOrganizationReportDataAsync_WithMismatchedOrgId_ThrowsBa // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null)); Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); @@ -887,7 +887,7 @@ public async Task UpdateOrganizationReportDataAsync_WithMismatchedReportId_Throw // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null)); Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); @@ -918,7 +918,7 @@ public async Task UpdateOrganizationReportDataAsync_CallsCorrectMethods( .Returns(expectedReport); // Act - await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); + await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null); // Assert await sutProvider.GetDependency() diff --git a/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs index 8f04afd1a490..77c120dcde5e 100644 --- a/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs @@ -1,7 +1,6 @@ using AutoFixture; using Bit.Core.AdminConsole.Entities; using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Enums; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Repositories; @@ -41,9 +40,7 @@ public async Task CreateAsync_Success_ReturnsReportWithSerializedFileData( // Assert Assert.NotNull(report); - Assert.Equal(OrganizationReportType.File, report.Type); - - // ReportData should contain serialized OrganizationReportFileData + // ReportData should contain serialized ReportFile Assert.NotEmpty(report.ReportData); var fileData = report.GetReportFileData(); Assert.NotNull(fileData); @@ -59,7 +56,6 @@ await sutProvider.GetDependency() .Received(1) .CreateAsync(Arg.Is(r => r.OrganizationId == request.OrganizationId && - r.Type == OrganizationReportType.File && r.SummaryData == request.SummaryData && r.ApplicationData == request.ApplicationData && r.ContentEncryptionKey == "test-encryption-key")); diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs index db7651b07355..c0d80586511c 100644 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs @@ -1,5 +1,4 @@ using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Enums; using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Dirt.Reports.Services; @@ -17,17 +16,17 @@ public class GetOrganizationReportDataV2QueryTests { private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid organizationId, string fileId) { - var fileData = new OrganizationReportFileData + var fileData = new ReportFile { Id = fileId, + FileName = "report-data.json", Validated = true }; var report = new OrganizationReport { Id = reportId, - OrganizationId = organizationId, - Type = OrganizationReportType.File + OrganizationId = organizationId }; report.SetReportFileData(fileData); return report; @@ -51,7 +50,7 @@ public async Task GetOrganizationReportDataAsync_Success_ReturnsDownloadUrl( .Returns(report); sutProvider.GetDependency() - .GetReportDataDownloadUrlAsync(report, Arg.Any()) + .GetReportDataDownloadUrlAsync(report, Arg.Any()) .Returns(expectedUrl); // Act @@ -63,7 +62,7 @@ public async Task GetOrganizationReportDataAsync_Success_ReturnsDownloadUrl( await sutProvider.GetDependency() .Received(1) - .GetReportDataDownloadUrlAsync(report, Arg.Any()); + .GetReportDataDownloadUrlAsync(report, Arg.Any()); } [Theory] @@ -136,8 +135,7 @@ public async Task GetOrganizationReportDataAsync_EmptyReportData_ThrowsNotFoundE { Id = reportId, OrganizationId = organizationId, - ReportData = string.Empty, - Type = OrganizationReportType.Data + ReportData = string.Empty }; sutProvider.GetDependency() diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs index 1e5dd0576920..0d7bcead329c 100644 --- a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs @@ -1,6 +1,5 @@ using AutoFixture; using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Enums; using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; @@ -18,17 +17,17 @@ public class UpdateOrganizationReportDataV2CommandTests { private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid organizationId, string fileId) { - var fileData = new OrganizationReportFileData + var fileData = new ReportFile { Id = fileId, + FileName = "report-data.json", Validated = false }; var report = new OrganizationReport { Id = reportId, - OrganizationId = organizationId, - Type = OrganizationReportType.File + OrganizationId = organizationId }; report.SetReportFileData(fileData); return report; diff --git a/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs new file mode 100644 index 000000000000..3d9974799d81 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs @@ -0,0 +1,154 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class ValidateOrganizationReportFileCommandTests +{ + private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid organizationId, string fileId) + { + var fileData = new ReportFile + { + Id = fileId, + FileName = "report-data.json", + Validated = false + }; + + var report = new OrganizationReport + { + Id = reportId, + OrganizationId = organizationId, + RevisionDate = DateTime.UtcNow.AddDays(-1) + }; + report.SetReportFileData(fileData); + return report; + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_ValidFile_SetsValidatedAndUpdatesReport( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + var organizationId = Guid.NewGuid(); + var fileId = "test-file-id-123"; + var report = CreateReportWithFileData(reportId, organizationId, fileId); + var originalRevisionDate = report.RevisionDate; + + sutProvider.GetDependency() + .ValidateFileAsync(report, Arg.Any(), 0, Core.Constants.FileSize501mb) + .Returns((true, 12345L)); + + // Act + var result = await sutProvider.Sut.ValidateAsync(report, fileId); + + // Assert + Assert.True(result); + + var fileData = report.GetReportFileData(); + Assert.NotNull(fileData); + Assert.True(fileData!.Validated); + Assert.Equal(12345L, fileData.Size); + Assert.True(report.RevisionDate > originalRevisionDate); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(report); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteReportFilesAsync(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_InvalidFile_DeletesBlobAndReport( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + var organizationId = Guid.NewGuid(); + var fileId = "test-file-id-456"; + var report = CreateReportWithFileData(reportId, organizationId, fileId); + + sutProvider.GetDependency() + .ValidateFileAsync(report, Arg.Any(), 0, Core.Constants.FileSize501mb) + .Returns((false, -1L)); + + // Act + var result = await sutProvider.Sut.ValidateAsync(report, fileId); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency() + .Received(1) + .DeleteReportFilesAsync(report, fileId); + + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(report); + + await sutProvider.GetDependency() + .DidNotReceive() + .ReplaceAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_NullFileData_ReturnsFalse( + SutProvider sutProvider) + { + // Arrange + var report = new OrganizationReport + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + ReportData = string.Empty + }; + + // Act + var result = await sutProvider.Sut.ValidateAsync(report, "any-file-id"); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency() + .DidNotReceive() + .ValidateFileAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_MismatchedFileId_ReturnsFalse( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + var organizationId = Guid.NewGuid(); + var report = CreateReportWithFileData(reportId, organizationId, "stored-file-id"); + + // Act + var result = await sutProvider.Sut.ValidateAsync(report, "different-file-id"); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency() + .DidNotReceive() + .ValidateFileAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs index a2243f88c41b..f66b9bee02f3 100644 --- a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs @@ -21,11 +21,12 @@ private static AzureOrganizationReportStorageService CreateSut() return new AzureOrganizationReportStorageService(globalSettings, logger); } - private static OrganizationReportFileData CreateFileData(string fileId = "test-file-id-123") + private static ReportFile CreateFileData(string fileId = "test-file-id-123") { - return new OrganizationReportFileData + return new ReportFile { Id = fileId, + FileName = "report-data.json", Validated = false }; } @@ -92,6 +93,18 @@ public async Task GetReportDataDownloadUrlAsync_ReturnsValidSasUrl() Assert.Contains("sp=", url); // Permissions (should be read-only) } + [Theory] + [InlineData("orgId/03-02-2026/reportId/fileId/report-data.json", "reportId")] + [InlineData("abc/01-01-2026/def/ghi/report-data.json", "def")] + public void ReportIdFromBlobName_ExtractsReportId(string blobName, string expectedReportId) + { + // Act + var result = AzureOrganizationReportStorageService.ReportIdFromBlobName(blobName); + + // Assert + Assert.Equal(expectedReportId, result); + } + [Fact] public async Task BlobPath_FormatsCorrectly() { diff --git a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs index 1c7521551d8c..69d8a2e843e4 100644 --- a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs @@ -19,11 +19,12 @@ private static Core.Settings.GlobalSettings GetGlobalSettings() return globalSettings; } - private static OrganizationReportFileData CreateFileData(string fileId = "test-file-id") + private static ReportFile CreateFileData(string fileId = "test-file-id") { - return new OrganizationReportFileData + return new ReportFile { Id = fileId, + FileName = "report-data.json", Validated = false }; } @@ -61,7 +62,7 @@ public async Task GetReportDataUploadUrlAsync_ReturnsApiEndpoint() var url = await sut.GetReportDataUploadUrlAsync(report, fileData); // Assert - Assert.Equal($"/reports/v2/organizations/{orgId}/{reportId}/file/report-data", url); + Assert.Equal($"/reports/organizations/{orgId}/{reportId}/file/report-data", url); } [Fact] @@ -119,9 +120,10 @@ public async Task UploadReportDataAsync_WithPathTraversalPayload_WritesOutsideBa .With(r => r.ReportData, string.Empty) .Create(); - var maliciousFileData = new OrganizationReportFileData + var maliciousFileData = new ReportFile { Id = maliciousFileId, + FileName = "report-data.json", Validated = false }; From d4a3f07433300ef382861e8c6a1190a8a68907ee Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:04:34 -0800 Subject: [PATCH 04/85] add feature flag for welcome dialog no ext prompt (#7144) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 761e292406d7..08d7f6c29254 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -262,6 +262,7 @@ public static class FeatureFlagKeys public const string PM29438_WelcomeDialogWithExtensionPrompt = "pm-29438-welcome-dialog-with-extension-prompt"; public const string PM29438_DialogWithExtensionPromptAccountAge = "pm-29438-dialog-with-extension-prompt-account-age"; public const string PM31039_ItemActionInExtension = "pm-31039-item-action-in-extension"; + public const string PM29437_WelcomeDialogNoExtPrompt = "pm-29437-welcome-dialog-no-ext-prompt"; /* Innovation Team */ public const string ArchiveVaultItems = "pm-19148-innovation-archive"; From f12da242d197dacc9d00b13cd4fe157c6b843fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 4 Mar 2026 22:09:09 +0100 Subject: [PATCH 05/85] [PM-32249] Allow custom desktop protocol in CORS (#7080) --- src/Core/Utilities/CoreHelpers.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 1461601d184a..c6815c31b0ba 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -647,6 +647,8 @@ public static bool IsCorsOriginAllowed(string origin, GlobalSettings globalSetti origin == globalSettings.BaseServiceUri.Vault || // Safari extension origin origin == "file://" || + // Desktop application custom file protocol + origin == "bw-desktop-file://bundle" || // Product website (!globalSettings.SelfHosted && origin == "https://bitwarden.com"); } From 48ce27bcd2456b6684d191070ed7f72244a490da Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Wed, 4 Mar 2026 22:30:30 +0100 Subject: [PATCH 06/85] Disabling Claude attribution (#7146) --- .claude/settings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.claude/settings.json b/.claude/settings.json index 8cf0d87c5c7f..aaaee4000c5c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,8 @@ { + "attribution": { + "commit": "", + "pr": "" + }, "extraKnownMarketplaces": { "bitwarden-marketplace": { "source": { From 2d81562b0d282d088d2c4ca42d9449ef4a40d016 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 4 Mar 2026 17:06:58 -0500 Subject: [PATCH 07/85] [PM-33140] Correct Non-Seat Plan Intial Seat Setting for Upgrade (#7140) * refactor(billing): update seat logic * test(billing): update tests for seat logic --- .../UpgradePremiumToOrganizationCommand.cs | 14 ++++++++------ ...UpgradePremiumToOrganizationCommandTests.cs | 18 +++++++++++------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index 08e442d39fe0..ffb7993c75e3 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -73,9 +73,6 @@ public Task> Run( return new BadRequest("User does not have an active Premium subscription."); } - // Hardcode seats to 1 for upgrade flow - const int seats = 1; - // Fetch the current Premium subscription from Stripe var currentSubscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId); @@ -96,6 +93,11 @@ public Task> Run( // Get the target organization plan var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType); + var isNonSeatBasedPmPlan = targetPlan.HasNonSeatBasedPasswordManagerPlan(); + + // if the target plan is non-seat-based, set seats to the base seats of the target plan, otherwise set to 1 + var initialSeats = isNonSeatBasedPmPlan ? targetPlan.PasswordManager.BaseSeats : 1; + // Build the list of subscription item updates var subscriptionItemOptions = new List(); @@ -113,7 +115,7 @@ public Task> Run( } // Add new organization subscription items - if (targetPlan.HasNonSeatBasedPasswordManagerPlan()) + if (isNonSeatBasedPmPlan) { subscriptionItemOptions.Add(new SubscriptionItemOptions { @@ -128,7 +130,7 @@ public Task> Run( { Id = passwordManagerItem.Id, Price = targetPlan.PasswordManager.StripeSeatPlanId, - Quantity = seats + Quantity = initialSeats }); } @@ -156,7 +158,7 @@ public Task> Run( Name = organizationName, BillingEmail = user.Email, PlanType = targetPlan.Type, - Seats = seats, + Seats = initialSeats, MaxCollections = targetPlan.PasswordManager.MaxCollections, MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb, UsePolicies = targetPlan.HasPolicies, diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index 0406c39bf452..158f08f048c7 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -29,7 +29,8 @@ public TestPlan( string? stripePlanId = null, string? stripeSeatPlanId = null, string? stripePremiumAccessPlanId = null, - string? stripeStoragePlanId = null) + string? stripeStoragePlanId = null, + int baseSeats = 1) { Type = planType; ProductTier = ProductTierType.Teams; @@ -68,7 +69,7 @@ public TestPlan( ProviderPortalSeatPrice = 0, AllowSeatAutoscale = true, HasAdditionalSeatsOption = true, - BaseSeats = 1, + BaseSeats = baseSeats, HasPremiumAccessOption = !string.IsNullOrEmpty(stripePremiumAccessPlanId), PremiumAccessOptionPrice = 0, MaxSeats = null, @@ -86,8 +87,9 @@ private static Core.Models.StaticStore.Plan CreateTestPlan( string? stripePlanId = null, string? stripeSeatPlanId = null, string? stripePremiumAccessPlanId = null, - string? stripeStoragePlanId = null) => - new TestPlan(planType, stripePlanId, stripeSeatPlanId, stripePremiumAccessPlanId, stripeStoragePlanId); + string? stripeStoragePlanId = null, + int baseSeats = 1) => + new TestPlan(planType, stripePlanId, stripeSeatPlanId, stripePremiumAccessPlanId, stripeStoragePlanId, baseSeats); private static PremiumPlan CreateTestPremiumPlan( string seatPriceId = "premium-annually", @@ -119,7 +121,7 @@ private static List CreateTestPremiumPlansList() return new List { // Current available plan - CreateTestPremiumPlan("premium-annually", "personal-storage-gb-annually", available: true), + CreateTestPremiumPlan(available: true), // Legacy plan from 2020 CreateTestPremiumPlan("premium-annually-2020", "personal-storage-gb-annually-2020", available: false) }; @@ -308,7 +310,8 @@ public async Task Run_SuccessfulUpgrade_NonSeatBasedPlan_ReturnsSuccess(User use var mockPlan = CreateTestPlan( PlanType.FamiliesAnnually, stripePlanId: "families-plan-annually", - stripeSeatPlanId: null // Non-seat-based + stripeSeatPlanId: null, // Non-seat-based + baseSeats: 6 ); _stripeAdapter.GetSubscriptionAsync("sub_123") @@ -338,7 +341,8 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync( opts.Items.Any(i => i.Id == "si_premium" && i.Price == "families-plan-annually" && i.Quantity == 1 && i.Deleted != true))); await _organizationRepository.Received(1).CreateAsync(Arg.Is(o => - o.Name == "My Families Org")); + o.Name == "My Families Org" && + o.Seats == 6)); await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.Premium == false && u.GatewaySubscriptionId == null)); From 8cd48f87d62b65ee1d9fbc4d8158b64b2cd404f8 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Wed, 4 Mar 2026 16:21:52 -0600 Subject: [PATCH 08/85] [PM-28531] Remove old proc and use new one (#7110) --- .../OrganizationReportsController.cs | 18 ++- src/Api/Startup.cs | 2 +- .../OrganizationReportSummaryDataResponse.cs | 12 +- .../AddOrganizationReportCommand.cs | 8 ++ ...zationReportSummaryDataByDateRangeQuery.cs | 69 +++++++--- .../ReportingServiceCollectionExtensions.cs | 6 +- .../UpdateOrganizationReportCommand.cs | 12 +- .../UpdateOrganizationReportSummaryCommand.cs | 12 +- .../OrganizationReportCacheConstants.cs | 44 +++++++ .../Dirt/OrganizationReportRepository.cs | 6 +- .../OrganizationReportRepository.cs | 12 +- .../Utilities/ServiceCollectionExtensions.cs | 2 +- ...nizationReport_GetSummariesByDateRange.sql | 17 --- ...rt_ReadByOrganizationIdAndRevisionDate.sql | 23 ++++ ...nReportSummaryDataByDateRangeQueryTests.cs | 124 +++++++++++++++++- .../UpdateOrganizationReportCommandTests.cs | 4 + ...teOrganizationReportSummaryCommandTests.cs | 4 + .../OrganizationReportRepositoryTests.cs | 69 +++++++++- ...rt_ReadByOrganizationIdAndRevisionDate.sql | 26 ++++ 19 files changed, 412 insertions(+), 58 deletions(-) create mode 100644 src/Core/Utilities/OrganizationReportCacheConstants.cs delete mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationIdAndRevisionDate.sql create mode 100644 util/Migrator/DbScripts/2026-02-27_00_AddOrganizationReport_ReadByOrganizationIdAndRevisionDate.sql diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index fc9a1b2d84a0..fc76efc07107 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -1,5 +1,6 @@ using Bit.Api.Dirt.Models.Response; using Bit.Core.Context; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Exceptions; @@ -130,7 +131,22 @@ public async Task UpdateOrganizationReportAsync(Guid organization # region SummaryData Field Endpoints + /// + /// Gets summary data for organization reports within a specified date range. + /// The response is optimized for widget display by returning up to 6 entries that are + /// evenly spaced across the date range, including the most recent entry. + /// This allows the widget to show trends over time while ensuring the latest data point is always included. + /// + /// + /// + /// + /// + /// + /// [HttpGet("{organizationId}/data/summary")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetOrganizationReportSummaryDataByDateRangeAsync( Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) { @@ -139,7 +155,7 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsyn throw new NotFoundException(); } - if (organizationId.Equals(null)) + if (organizationId == Guid.Empty) { throw new BadRequestException("Organization ID is required."); } diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 7ac9c2813950..44398cfc726c 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -185,7 +185,7 @@ public void ConfigureServices(IServiceCollection services) services.AddOrganizationSubscriptionServices(); services.AddCoreLocalizationServices(); services.AddBillingOperations(); - services.AddReportingServices(); + services.AddReportingServices(globalSettings); services.AddImportServices(); services.AddSendServices(); diff --git a/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs index 0533c2862f79..5c4765db4656 100644 --- a/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs +++ b/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs @@ -1,6 +1,14 @@ -namespace Bit.Core.Dirt.Models.Data; +using System.Text.Json.Serialization; + +namespace Bit.Core.Dirt.Models.Data; public class OrganizationReportSummaryDataResponse { - public string? SummaryData { get; set; } + public required Guid OrganizationId { get; set; } + [JsonPropertyName("encryptedData")] + public required string SummaryData { get; set; } + [JsonPropertyName("encryptionKey")] + public required string ContentEncryptionKey { get; set; } + [JsonPropertyName("date")] + public required DateTime RevisionDate { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs index 236560487e92..7c2dc66f604c 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs @@ -4,7 +4,10 @@ using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Core.Dirt.Reports.ReportFeatures; @@ -12,15 +15,18 @@ public class AddOrganizationReportCommand : IAddOrganizationReportCommand { private readonly IOrganizationRepository _organizationRepo; private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IFusionCache _cache; private ILogger _logger; public AddOrganizationReportCommand( IOrganizationRepository organizationRepository, IOrganizationReportRepository organizationReportRepository, + [FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache, ILogger logger) { _organizationRepo = organizationRepository; _organizationReportRepo = organizationReportRepository; + _cache = cache; _logger = logger; } @@ -64,6 +70,8 @@ public async Task AddOrganizationReportAsync(AddOrganization var data = await _organizationReportRepo.CreateAsync(organizationReport); + await _cache.RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId)); + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully added organization report for organization {organizationId}, {organizationReportId}", request.OrganizationId, data.Id); diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs index 7be59b822ee5..6d2d80bb3e66 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs @@ -2,20 +2,27 @@ using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Core.Dirt.Reports.ReportFeatures; public class GetOrganizationReportSummaryDataByDateRangeQuery : IGetOrganizationReportSummaryDataByDateRangeQuery { + private const int MaxRecordsForWidget = 6; private readonly IOrganizationReportRepository _organizationReportRepo; private readonly ILogger _logger; + private readonly IFusionCache _cache; public GetOrganizationReportSummaryDataByDateRangeQuery( IOrganizationReportRepository organizationReportRepo, + [FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache, ILogger logger) { _organizationReportRepo = organizationReportRepo; + _cache = cache; _logger = logger; } @@ -23,7 +30,7 @@ public async Task> GetOrganiz { try { - _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report summary data by date range for organization {organizationId}, from {startDate} to {endDate}", + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report summary data by date range for organization {OrganizationId}, from {StartDate} to {EndDate}", organizationId, startDate, endDate); var (isValid, errorMessage) = ValidateRequest(organizationId, startDate, endDate); @@ -33,30 +40,35 @@ public async Task> GetOrganiz throw new BadRequestException(errorMessage); } - IEnumerable summaryDataList = (await _organizationReportRepo - .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate)) ?? - Enumerable.Empty(); + // update start and end date to include the entire day + startDate = startDate.Date; + endDate = endDate.Date.AddDays(1).AddTicks(-1); - var resultList = summaryDataList.ToList(); + // cache key and tag + var cacheKey = OrganizationReportCacheConstants.BuildCacheKeyForSummaryDataByDateRange(organizationId, startDate, endDate); + var cacheTag = OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(organizationId); - if (!resultList.Any()) - { - _logger.LogInformation(Constants.BypassFiltersEventId, "No summary data found for organization {organizationId} in date range {startDate} to {endDate}", - organizationId, startDate, endDate); - return Enumerable.Empty(); - } - else - { - _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved {count} organization report summary data records for organization {organizationId} in date range {startDate} to {endDate}", - resultList.Count, organizationId, startDate, endDate); + var summaryDataList = await _cache.GetOrSetAsync( + key: cacheKey, + factory: async _ => + { + var data = await _organizationReportRepo.GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + return GetMostRecentEntries(data); + }, + options: new FusionCacheEntryOptions(duration: OrganizationReportCacheConstants.DurationForSummaryData), + tags: [cacheTag] + ); - } + var resultList = summaryDataList?.ToList() ?? Enumerable.Empty().ToList(); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetched {Count} organization report summary data entries for organization {OrganizationId}, from {StartDate} to {EndDate}", + resultList.Count, organizationId, startDate, endDate); return resultList; } catch (Exception ex) when (!(ex is BadRequestException)) { - _logger.LogError(ex, "Error fetching organization report summary data by date range for organization {organizationId}, from {startDate} to {endDate}", + _logger.LogError(ex, "Error fetching organization report summary data by date range for organization {OrganizationId}, from {StartDate} to {EndDate}", organizationId, startDate, endDate); throw; } @@ -86,4 +98,27 @@ private static (bool IsValid, string errorMessage) ValidateRequest(Guid organiza return (true, string.Empty); } + + private static IEnumerable GetMostRecentEntries(IEnumerable data, int maxEntries = MaxRecordsForWidget) + { + if (data.Count() <= maxEntries) + { + return data; + } + + // here we need to take 10 records, evenly spaced by RevisionDate, + // to cover the entire date range, + // and ensure we include the most recent record as well + var sortedData = data.OrderByDescending(d => d.RevisionDate).ToList(); + var totalRecords = sortedData.Count; + var interval = (double)(totalRecords - 1) / (maxEntries - 1); // -1 the most recent record will be included by default + var result = new List(); + + for (int i = 0; i <= maxEntries - 1; i++) + { + result.Add(sortedData[(int)Math.Round(i * interval)]); + } + + return result; + } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index f89ff977624f..fbbc6967395a 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -1,13 +1,17 @@ using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; +using Bit.Core.Settings; +using Bit.Core.Utilities; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Dirt.Reports.ReportFeatures; public static class ReportingServiceCollectionExtensions { - public static void AddReportingServices(this IServiceCollection services) + public static void AddReportingServices(this IServiceCollection services, IGlobalSettings globalSettings) { + services.AddExtendedCache(OrganizationReportCacheConstants.CacheName, (GlobalSettings)globalSettings); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs index 7fb77030a85a..32b1815ebeea 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs @@ -4,7 +4,10 @@ using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Core.Dirt.Reports.ReportFeatures; @@ -13,15 +16,17 @@ public class UpdateOrganizationReportCommand : IUpdateOrganizationReportCommand private readonly IOrganizationRepository _organizationRepo; private readonly IOrganizationReportRepository _organizationReportRepo; private readonly ILogger _logger; - + private readonly IFusionCache _cache; public UpdateOrganizationReportCommand( IOrganizationRepository organizationRepository, IOrganizationReportRepository organizationReportRepository, - ILogger logger) + ILogger logger, + [FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache) { _organizationRepo = organizationRepository; _organizationReportRepo = organizationReportRepository; _logger = logger; + _cache = cache; } public async Task UpdateOrganizationReportAsync(UpdateOrganizationReportRequest request) @@ -61,6 +66,9 @@ public async Task UpdateOrganizationReportAsync(UpdateOrgani await _organizationReportRepo.UpsertAsync(existingReport); + // Invalidate cache + await _cache.RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId)); + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report {reportId} for organization {organizationId}", request.ReportId, request.OrganizationId); diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs index 5d0f2670ca76..a0e6c56a0fbc 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs @@ -5,7 +5,10 @@ using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Core.Dirt.Reports.ReportFeatures; @@ -14,15 +17,17 @@ public class UpdateOrganizationReportSummaryCommand : IUpdateOrganizationReportS private readonly IOrganizationRepository _organizationRepo; private readonly IOrganizationReportRepository _organizationReportRepo; private readonly ILogger _logger; - + private readonly IFusionCache _cache; public UpdateOrganizationReportSummaryCommand( IOrganizationRepository organizationRepository, IOrganizationReportRepository organizationReportRepository, - ILogger logger) + ILogger logger, + [FromKeyedServices(OrganizationReportCacheConstants.CacheName)] IFusionCache cache) { _organizationRepo = organizationRepository; _organizationReportRepo = organizationReportRepository; _logger = logger; + _cache = cache; } public async Task UpdateOrganizationReportSummaryAsync(UpdateOrganizationReportSummaryRequest request) @@ -57,6 +62,9 @@ public async Task UpdateOrganizationReportSummaryAsync(Updat await _organizationReportRepo.UpdateMetricsAsync(request.ReportId, OrganizationReportMetricsData.From(request.OrganizationId, request.Metrics)); var updatedReport = await _organizationReportRepo.UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData ?? string.Empty); + // Invalidate cache + await _cache.RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId)); + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report summary {reportId} for organization {organizationId}", request.ReportId, request.OrganizationId); diff --git a/src/Core/Utilities/OrganizationReportCacheConstants.cs b/src/Core/Utilities/OrganizationReportCacheConstants.cs new file mode 100644 index 000000000000..575bf79b83f6 --- /dev/null +++ b/src/Core/Utilities/OrganizationReportCacheConstants.cs @@ -0,0 +1,44 @@ +namespace Bit.Core.Utilities; + +/// +/// Provides cache key generation helpers and cache name constants for organization report–related entities. +/// +public static class OrganizationReportCacheConstants +{ + /// + /// The cache name used for storing organization report data. + /// + public const string CacheName = "OrganizationReports"; + + /// + /// Duration TimeSpan for caching organization report summary data. + /// Consider: Reports might be regenerated daily, so cache for shorter periods. + /// + public static readonly TimeSpan DurationForSummaryData = TimeSpan.FromHours(6); + + /// + /// Builds a deterministic cache key for organization report summary data by date range. + /// + /// The unique identifier of the organization. + /// The start date of the date range. + /// The end date of the date range. + /// + /// A cache key for the organization report summary data. + /// + public static string BuildCacheKeyForSummaryDataByDateRange( + Guid organizationId, + DateTime startDate, + DateTime endDate) + => $"OrganizationReportSummaryData:{organizationId:N}:{startDate:yyyy-MM-dd}:{endDate:yyyy-MM-dd}"; + + /// + /// Builds a cache tag for an organization's report data. + /// Used for bulk invalidation when organization reports are updated. + /// + /// The unique identifier of the organization. + /// + /// A cache tag for the organization's reports. + /// + public static string BuildCacheTagForOrganizationReports(Guid organizationId) + => $"OrganizationReports:{organizationId:N}"; +} diff --git a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs index c704a208d170..0472efaac192 100644 --- a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs +++ b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs @@ -86,12 +86,12 @@ public async Task> GetSummary var parameters = new { OrganizationId = organizationId, - StartDate = startDate, - EndDate = endDate + StartDate = startDate.ToUniversalTime(), + EndDate = endDate.ToUniversalTime() }; var results = await connection.QueryAsync( - $"[{Schema}].[OrganizationReport_GetSummariesByDateRange]", + $"[{Schema}].[OrganizationReport_ReadByOrganizationIdAndRevisionDate]", parameters, commandType: CommandType.StoredProcedure); diff --git a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs index d08e70c35325..c06519f12a16 100644 --- a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs +++ b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs @@ -72,7 +72,10 @@ public async Task GetSummaryDataAsync(Gui .Where(p => p.Id == reportId) .Select(p => new OrganizationReportSummaryDataResponse { - SummaryData = p.SummaryData + OrganizationId = p.OrganizationId, + ContentEncryptionKey = p.ContentEncryptionKey, + SummaryData = p.SummaryData, + RevisionDate = p.RevisionDate }) .FirstOrDefaultAsync(); @@ -91,10 +94,13 @@ public async Task> GetSummary var results = await dbContext.OrganizationReports .Where(p => p.OrganizationId == organizationId && - p.CreationDate >= startDate && p.CreationDate <= endDate) + p.RevisionDate >= startDate && p.RevisionDate <= endDate) .Select(p => new OrganizationReportSummaryDataResponse { - SummaryData = p.SummaryData + OrganizationId = p.OrganizationId, + ContentEncryptionKey = p.ContentEncryptionKey, + SummaryData = p.SummaryData, + RevisionDate = p.RevisionDate }) .ToListAsync(); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 8d65547f7621..18675169d550 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -170,7 +170,7 @@ public static void AddBaseServices(this IServiceCollection services, IGlobalSett services.AddScoped(); services.AddScoped(); services.AddVaultServices(); - services.AddReportingServices(); + services.AddReportingServices(globalSettings); services.AddKeyManagementServices(); services.AddNotificationCenterServices(); services.AddPlatformServices(); diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql deleted file mode 100644 index 2ab78a2a1e75..000000000000 --- a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE PROCEDURE [dbo].[OrganizationReport_GetSummariesByDateRange] - @OrganizationId UNIQUEIDENTIFIER, - @StartDate DATETIME2(7), - @EndDate DATETIME2(7) -AS -BEGIN - SET NOCOUNT ON - - SELECT - [SummaryData] - FROM [dbo].[OrganizationReportView] - WHERE [OrganizationId] = @OrganizationId - AND [RevisionDate] >= @StartDate - AND [RevisionDate] <= @EndDate - ORDER BY [RevisionDate] DESC -END - diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationIdAndRevisionDate.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationIdAndRevisionDate.sql new file mode 100644 index 000000000000..5ccfcd872cc5 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationIdAndRevisionDate.sql @@ -0,0 +1,23 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_ReadByOrganizationIdAndRevisionDate] + @OrganizationId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [OrganizationId], + [ContentEncryptionKey], + [SummaryData], + [RevisionDate] + FROM + [dbo].[OrganizationReportView] + WHERE + [OrganizationId] = @OrganizationId + AND [RevisionDate] >= @StartDate + AND [RevisionDate] <= @EndDate + ORDER BY + [RevisionDate] DESC +END +GO diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs index 572b7e21fb48..9cf965fdd97d 100644 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs @@ -8,6 +8,7 @@ using NSubstitute; using NSubstitute.ExceptionExtensions; using Xunit; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Core.Test.Dirt.ReportFeatures; @@ -26,12 +27,29 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithValidPara var startDate = DateTime.UtcNow.AddDays(-30); var endDate = DateTime.UtcNow; var summaryDataList = fixture.Build() - .CreateMany(3); + .CreateMany(3).ToList(); + summaryDataList[0].RevisionDate = DateTime.UtcNow; // most recent + summaryDataList[1].RevisionDate = DateTime.UtcNow.AddDays(-1); + summaryDataList[2].RevisionDate = DateTime.UtcNow.AddDays(-2); sutProvider.GetDependency() - .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate) + .GetSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(summaryDataList); + sutProvider + .GetDependency() + .GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any>>>(), + options: Arg.Any(), + tags: Arg.Any>()) + .Returns(callInfo => + { + var factory = callInfo.ArgAt>, CancellationToken, Task>>>(1); + return new ValueTask>(factory.Invoke(null, CancellationToken.None)); + }); + + // Act var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); @@ -39,9 +57,65 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithValidPara Assert.NotNull(result); Assert.Equal(3, result.Count()); await sutProvider.GetDependency() - .Received(1).GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + .Received(1).GetSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_ShouldReturnTopSixResults( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = fixture.Create(); + var reportId = fixture.Create(); + var startDate = DateTime.UtcNow.AddDays(-30); + var endDate = DateTime.UtcNow; + var summaryDataList = fixture.Build() + .CreateMany(12) + .ToList(); + summaryDataList[0].RevisionDate = DateTime.UtcNow; // most recent + summaryDataList[1].RevisionDate = DateTime.UtcNow.AddDays(-1); + summaryDataList[2].RevisionDate = DateTime.UtcNow.AddDays(-2); + summaryDataList[3].RevisionDate = DateTime.UtcNow.AddDays(-3); + summaryDataList[4].RevisionDate = DateTime.UtcNow.AddDays(-4); + summaryDataList[5].RevisionDate = DateTime.UtcNow.AddDays(-5); + summaryDataList[6].RevisionDate = DateTime.UtcNow.AddDays(-6); + summaryDataList[7].RevisionDate = DateTime.UtcNow.AddDays(-7); + summaryDataList[8].RevisionDate = DateTime.UtcNow.AddDays(-8); + summaryDataList[9].RevisionDate = DateTime.UtcNow.AddDays(-9); + summaryDataList[10].RevisionDate = DateTime.UtcNow.AddDays(-10); + summaryDataList[11].RevisionDate = DateTime.UtcNow.AddDays(-11); + + sutProvider.GetDependency() + .GetSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(summaryDataList); + + sutProvider + .GetDependency() + .GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any>>>(), + options: Arg.Any(), + tags: Arg.Any>()) + .Returns(callInfo => + { + var factory = callInfo.ArgAt>, CancellationToken, Task>>>(1); + return new ValueTask>(factory.Invoke(null, CancellationToken.None)); + }); + + + // Act + var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + + // Assert + Assert.NotNull(result); + Assert.Equal(6, result.Count()); + await sutProvider.GetDependency() + .Received(1).GetSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()); } + [Theory] [BitAutoData] public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( @@ -100,6 +174,20 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithEmptyResu .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate) .Returns(new List()); + sutProvider + .GetDependency() + .GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any>>>(), + options: Arg.Any(), + tags: Arg.Any>()) + .Returns(callInfo => + { + var factory = callInfo.ArgAt>, CancellationToken, Task>>>(1); + return new ValueTask>(factory.Invoke(null, CancellationToken.None)); + }); + + // Act var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); @@ -120,14 +208,36 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WhenRepositor var endDate = DateTime.UtcNow; var expectedMessage = "Database connection failed"; - sutProvider.GetDependency() + var repo = sutProvider.GetDependency(); + + repo .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate) .Throws(new InvalidOperationException(expectedMessage)); + sutProvider + .GetDependency() + .GetOrSetAsync( + key: Arg.Any(), + factory: Arg.Any>>>(), + options: Arg.Any(), + tags: Arg.Any>()) + .Returns(callInfo => + { + var factory = callInfo.ArgAt>, CancellationToken, Task>>>(1); + return new ValueTask>(factory.Invoke(null, CancellationToken.None)); + }); + + // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate)); + // var exception = await Assert.ThrowsAsync(async () => + // await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate)); - Assert.Equal(expectedMessage, exception.Message); + var results = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + + // Assert + // since the IFusionCache has a failsafe, + // the exception from the repository should be caught and logged, and an empty list should be returned + Assert.NotNull(results); + Assert.Empty(results); } } diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs index 3a84eb0d8008..1a4b8a51e66b 100644 --- a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs @@ -6,10 +6,12 @@ using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Core.Test.Dirt.ReportFeatures; @@ -68,6 +70,8 @@ await sutProvider.GetDependency() .Received(2).GetByIdAsync(request.ReportId); await sutProvider.GetDependency() .Received(1).UpsertAsync(Arg.Any()); + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId)); } [Theory] diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs index dae3ff35bae2..59507a63b862 100644 --- a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs @@ -6,11 +6,13 @@ using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using NSubstitute.ExceptionExtensions; using Xunit; +using ZiggyCreatures.Caching.Fusion; namespace Bit.Core.Test.Dirt.ReportFeatures; @@ -59,6 +61,8 @@ public async Task UpdateOrganizationReportSummaryAsync_WithValidRequest_ShouldRe Assert.Equal(updatedReport.OrganizationId, result.OrganizationId); await sutProvider.GetDependency() .Received(1).UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData); + await sutProvider.GetDependency().Received(1) + .RemoveByTagAsync(OrganizationReportCacheConstants.BuildCacheTagForOrganizationReports(request.OrganizationId)); } [Theory] diff --git a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs index f2613fd2417b..345e7366e5c1 100644 --- a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs @@ -1,6 +1,7 @@ using AutoFixture; using Bit.Core.AdminConsole.Entities; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.Models.Data; using Bit.Core.Dirt.Repositories; using Bit.Core.Repositories; @@ -353,6 +354,69 @@ public async Task GetSummaryDataByDateRangeAsync_ShouldReturnFilteredResults( Assert.All(resultsList, r => Assert.NotNull(r.SummaryData)); } + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetSummaryDataByDateRangeAsync_ForAllEFProviders_ShouldReturnFilteredResults( + Organization organization, + List suts, + List efOrganizationRepos) + { + // Arrange + var baseDate = DateTime.UtcNow; + var startDate = baseDate.AddDays(-10); + var endDate = baseDate.AddDays(1); + var fixture = new Fixture(); + var responses = new List>(); + + foreach (var sut in suts) + { + var index = suts.IndexOf(sut); + + // Create organization first + var org = await efOrganizationRepos[index].CreateAsync(organization); + + // Create first report with a date within range + var report1 = fixture.Build() + .With(x => x.OrganizationId, org.Id) + .With(x => x.SummaryData, "Summary 1") + .With(x => x.CreationDate, baseDate.AddDays(-5)) // Within range + .With(x => x.RevisionDate, baseDate.AddDays(-5)) + .Create(); + await sut.CreateAsync(report1); + + // Create second report with a date within range + var report2 = fixture.Build() + .With(x => x.OrganizationId, org.Id) + .With(x => x.SummaryData, "Summary 2") + .With(x => x.CreationDate, baseDate.AddDays(-3)) // within range + .With(x => x.RevisionDate, baseDate.AddDays(-3)) + .Create(); + await sut.CreateAsync(report2); + + // Create third report with a date not within range + var report3 = fixture.Build() + .With(x => x.OrganizationId, org.Id) + .With(x => x.SummaryData, "Summary 3") + .With(x => x.CreationDate, baseDate.AddDays(-20)) // not in range + .With(x => x.RevisionDate, baseDate.AddDays(-20)) + .Create(); + await sut.CreateAsync(report3); + + // Act + var results = await sut.GetSummaryDataByDateRangeAsync(org.Id, startDate, endDate); + responses.Add(results); + } + + // Assert + Assert.NotNull(responses); + foreach (var results in responses) + { + var resultsList = results.ToList(); + Assert.True(resultsList.Count >= 2, $"Expected at least 2 results, but got {resultsList.Count}"); + Assert.All(resultsList, r => Assert.NotNull(r.SummaryData)); + Assert.All(resultsList, r => Assert.NotNull(r.ContentEncryptionKey)); + } + } + [CiSkippedTheory, EfOrganizationReportAutoData] public async Task GetReportDataAsync_ShouldReturnReportData( OrganizationReportRepository sqlOrganizationReportRepo, @@ -538,7 +602,10 @@ public async Task UpdateMetricsAsync_ShouldUpdateMetricsCorrectly( IOrganizationReportRepository orgReportRepo) { var fixture = new Fixture(); - var organization = fixture.Create(); + var organization = fixture.Build() + .With(x => x.CreationDate, DateTime.UtcNow) + .With(x => x.RevisionDate, DateTime.UtcNow) + .Create(); var orgReportRecord = fixture.Build() .With(x => x.OrganizationId, organization.Id) diff --git a/util/Migrator/DbScripts/2026-02-27_00_AddOrganizationReport_ReadByOrganizationIdAndRevisionDate.sql b/util/Migrator/DbScripts/2026-02-27_00_AddOrganizationReport_ReadByOrganizationIdAndRevisionDate.sql new file mode 100644 index 000000000000..e83ecbb2fae6 --- /dev/null +++ b/util/Migrator/DbScripts/2026-02-27_00_AddOrganizationReport_ReadByOrganizationIdAndRevisionDate.sql @@ -0,0 +1,26 @@ +DROP PROC IF EXISTS [dbo].[OrganizationReport_GetSummariesByDateRange] +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_ReadByOrganizationIdAndRevisionDate] + @OrganizationId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [OrganizationId], + [ContentEncryptionKey], + [SummaryData], + [RevisionDate] + FROM + [dbo].[OrganizationReportView] + WHERE + [OrganizationId] = @OrganizationId + AND [RevisionDate] >= @StartDate + AND [RevisionDate] <= @EndDate + ORDER BY + [RevisionDate] DESC +END +GO From be34ff2670af02ac2e10ce4b22aea0216ab7cec1 Mon Sep 17 00:00:00 2001 From: sven-bitwarden Date: Wed, 4 Mar 2026 16:54:05 -0600 Subject: [PATCH 09/85] Update PoliciesController.Put to forward all behavior to VNext (#7130) --- .../Controllers/PoliciesController.cs | 9 +--- .../Controllers/PoliciesControllerTests.cs | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index 3a7631ed7746..f17e7bce32aa 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -183,14 +183,7 @@ public async Task GetMasterPasswordPolicy(Guid orgId) [HttpPut("{type}")] public async Task Put(Guid orgId, PolicyType type, [FromBody] PolicyRequestModel model) { - if (!await _currentContext.ManagePolicies(orgId)) - { - throw new NotFoundException(); - } - - var policyUpdate = await model.ToPolicyUpdateAsync(orgId, type, _currentContext); - var policy = await _savePolicyCommand.SaveAsync(policyUpdate); - return new PolicyResponseModel(policy); + return await PutVNext(orgId, type, new SavePolicyRequest { Policy = model }); } [HttpPut("{type}/vnext")] diff --git a/test/Api.Test/Controllers/PoliciesControllerTests.cs b/test/Api.Test/Controllers/PoliciesControllerTests.cs index 03ab20ec28e2..f8b8f5ad1d6b 100644 --- a/test/Api.Test/Controllers/PoliciesControllerTests.cs +++ b/test/Api.Test/Controllers/PoliciesControllerTests.cs @@ -440,6 +440,48 @@ Organization organization Assert.Equal(enabledPolicy.Enabled, expectedPolicy.Enabled); } + [Theory] + [BitAutoData] + public async Task Put_UsesVNextSavePolicyCommand( + SutProvider sutProvider, Guid orgId, + SavePolicyRequest model, Policy policy, Guid userId) + { + // Arrange + policy.Data = null; + + sutProvider.GetDependency() + .UserId + .Returns(userId); + + sutProvider.GetDependency() + .OrganizationOwner(orgId) + .Returns(true); + + sutProvider.GetDependency() + .SaveAsync(Arg.Any()) + .Returns(policy); + + // Act + var result = await sutProvider.Sut.Put(orgId, policy.Type, model.Policy); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is(m => m.PolicyUpdate.OrganizationId == orgId && + m.PolicyUpdate.Type == policy.Type && + m.PolicyUpdate.Enabled == model.Policy.Enabled && + m.PerformedBy.UserId == userId && + m.PerformedBy.IsOrganizationOwnerOrProvider == true)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .VNextSaveAsync(default); + + Assert.NotNull(result); + Assert.Equal(policy.Id, result.Id); + Assert.Equal(policy.Type, result.Type); + } + [Theory] [BitAutoData] public async Task PutVNext_UsesVNextSavePolicyCommand( From b58003c889a4c67fb023a4fb4b02bd04a4b77c4c Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Wed, 4 Mar 2026 23:36:04 -0600 Subject: [PATCH 10/85] PM-31923 adding request size attributes --- src/Api/Dirt/Controllers/OrganizationReportsController.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index 65e702c64655..2ffd4baaf639 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -136,6 +136,7 @@ public async Task GetOrganizationReportAsync(Guid organizationId, } [HttpPost("{organizationId}")] + [RequestSizeLimit(Constants.FileSize501mb)] public async Task CreateOrganizationReportAsync( Guid organizationId, [FromBody] AddOrganizationReportRequest request) @@ -181,6 +182,7 @@ public async Task CreateOrganizationReportAsync( } [HttpPatch("{organizationId}/{reportId}")] + [RequestSizeLimit(Constants.FileSize501mb)] public async Task UpdateOrganizationReportAsync(Guid organizationId, [FromBody] UpdateOrganizationReportRequest request) { if (!await _currentContext.AccessReports(organizationId)) From 2abe9b17e0d3d284e458b9b2446b93a9b5d0d9d3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:44:58 +0100 Subject: [PATCH 11/85] [deps]: Update actions/checkout action to v6.0.2 (#6904) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/_move_edd_db_scripts.yml | 4 ++-- .github/workflows/build.yml | 8 ++++---- .github/workflows/cleanup-rc-branch.yml | 2 +- .github/workflows/code-references.yml | 2 +- .github/workflows/load-test.yml | 2 +- .github/workflows/protect-files.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/repository-management.yml | 4 ++-- .github/workflows/test-database.yml | 6 +++--- .github/workflows/test.yml | 2 +- 10 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/_move_edd_db_scripts.yml b/.github/workflows/_move_edd_db_scripts.yml index 742e7b897e3d..91d069da8db7 100644 --- a/.github/workflows/_move_edd_db_scripts.yml +++ b/.github/workflows/_move_edd_db_scripts.yml @@ -38,7 +38,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Check out branch - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} persist-credentials: false @@ -68,7 +68,7 @@ jobs: if: ${{ needs.setup.outputs.copy_edd_scripts == 'true' }} steps: - name: Check out repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 persist-credentials: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5fd6a8b3efaa..a431b9242f9d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -101,7 +101,7 @@ jobs: echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT" - name: Check out repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -288,7 +288,7 @@ jobs: actions: read steps: - name: Check out repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false @@ -414,7 +414,7 @@ jobs: - win-x64 steps: - name: Check out repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false diff --git a/.github/workflows/cleanup-rc-branch.yml b/.github/workflows/cleanup-rc-branch.yml index ae482ef4e649..3ccccbdda163 100644 --- a/.github/workflows/cleanup-rc-branch.yml +++ b/.github/workflows/cleanup-rc-branch.yml @@ -31,7 +31,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Checkout main - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: main token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml index cb7ca9e2008c..da3bcbae8934 100644 --- a/.github/workflows/code-references.yml +++ b/.github/workflows/code-references.yml @@ -36,7 +36,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index 10bfe50d109b..1d8ebc4d1de6 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -87,7 +87,7 @@ jobs: datadog/agent:7-full@sha256:7ea933dec3b8baa8c19683b1c3f6f801dbf3291f748d9ed59234accdaac4e479 - name: Check out repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/protect-files.yml b/.github/workflows/protect-files.yml index 4b137eb2212a..357daf2e5bce 100644 --- a/.github/workflows/protect-files.yml +++ b/.github/workflows/protect-files.yml @@ -31,7 +31,7 @@ jobs: label: "DB-migrations-changed" steps: - name: Check out repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 persist-credentials: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3c4fb1ffde8..e3b93e0c96f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: fi - name: Check out repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index c98faed34038..2ed0bfb73606 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -91,7 +91,7 @@ jobs: permission-contents: write - name: Check out branch - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: main token: ${{ steps.app-token.outputs.token }} @@ -215,7 +215,7 @@ jobs: permission-contents: write - name: Check out target ref - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.target_ref }} token: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index 25ff9d048836..711b4f2a0a23 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -44,7 +44,7 @@ jobs: checks: write steps: - name: Check out repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -178,7 +178,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -269,7 +269,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d1d2a3465db1..6d8c6e347b1e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Check out repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false From 7c6d506a7bbc3011f63ac6fe09f5c4987c7b43e9 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 5 Mar 2026 08:47:41 -0600 Subject: [PATCH 12/85] Return WebAuthn credential record in create response (#7145) * Return WebAuthn credential record in create response * Make CreateWebAuthnLoginCredentialCommand null-safe --- .../Auth/Controllers/WebAuthnController.cs | 8 ++-- .../ICreateWebAuthnLoginCredentialCommand.cs | 6 +-- .../CreateWebAuthnLoginCredentialCommand.cs | 15 +++---- .../Controllers/WebAuthnControllerTests.cs | 42 ++++++++++++------- ...eateWebAuthnLoginCredentialCommandTests.cs | 4 +- 5 files changed, 45 insertions(+), 30 deletions(-) diff --git a/src/Api/Auth/Controllers/WebAuthnController.cs b/src/Api/Auth/Controllers/WebAuthnController.cs index 833087e99cc2..821d9e9d9c3b 100644 --- a/src/Api/Auth/Controllers/WebAuthnController.cs +++ b/src/Api/Auth/Controllers/WebAuthnController.cs @@ -108,7 +108,7 @@ public async Task AssertionOptions([ [Authorize(Policies.Application)] [HttpPost("")] - public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel model) + public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel model) { var user = await GetUserAsync(); await ValidateIfUserCanUsePasskeyLogin(user.Id); @@ -119,11 +119,13 @@ public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel mode throw new BadRequestException("The token associated with your request is expired. A valid token is required to continue."); } - var success = await _createWebAuthnLoginCredentialCommand.CreateWebAuthnLoginCredentialAsync(user, model.Name, tokenable.Options, model.DeviceResponse, model.SupportsPrf, model.EncryptedUserKey, model.EncryptedPublicKey, model.EncryptedPrivateKey); - if (!success) + var credential = await _createWebAuthnLoginCredentialCommand.CreateWebAuthnLoginCredentialAsync(user, model.Name, tokenable.Options, model.DeviceResponse, model.SupportsPrf, model.EncryptedUserKey, model.EncryptedPublicKey, model.EncryptedPrivateKey); + if (credential == null) { throw new BadRequestException("Unable to complete WebAuthn registration."); } + + return new WebAuthnCredentialResponseModel(credential); } private async Task ValidateRequireSsoPolicyDisabledOrNotApplicable(Guid userId) diff --git a/src/Core/Auth/UserFeatures/WebAuthnLogin/ICreateWebAuthnLoginCredentialCommand.cs b/src/Core/Auth/UserFeatures/WebAuthnLogin/ICreateWebAuthnLoginCredentialCommand.cs index 61a573cb2d81..295cd6e119a1 100644 --- a/src/Core/Auth/UserFeatures/WebAuthnLogin/ICreateWebAuthnLoginCredentialCommand.cs +++ b/src/Core/Auth/UserFeatures/WebAuthnLogin/ICreateWebAuthnLoginCredentialCommand.cs @@ -1,6 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - +using Bit.Core.Auth.Entities; using Bit.Core.Entities; using Fido2NetLib; @@ -8,5 +6,5 @@ namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin; public interface ICreateWebAuthnLoginCredentialCommand { - public Task CreateWebAuthnLoginCredentialAsync(User user, string name, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse, bool supportsPrf, string encryptedUserKey = null, string encryptedPublicKey = null, string encryptedPrivateKey = null); + public Task CreateWebAuthnLoginCredentialAsync(User user, string name, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse, bool supportsPrf, string? encryptedUserKey = null, string? encryptedPublicKey = null, string? encryptedPrivateKey = null); } diff --git a/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs b/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs index 795fa95b9d9a..92c95d1ecf18 100644 --- a/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs +++ b/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Repositories; using Bit.Core.Entities; using Bit.Core.Utilities; @@ -22,18 +19,22 @@ public CreateWebAuthnLoginCredentialCommand(IFido2 fido2, IWebAuthnCredentialRep _webAuthnCredentialRepository = webAuthnCredentialRepository; } - public async Task CreateWebAuthnLoginCredentialAsync(User user, string name, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse, bool supportsPrf, string encryptedUserKey = null, string encryptedPublicKey = null, string encryptedPrivateKey = null) + public async Task CreateWebAuthnLoginCredentialAsync(User user, string name, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse, bool supportsPrf, string? encryptedUserKey = null, string? encryptedPublicKey = null, string? encryptedPrivateKey = null) { var existingCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id); if (existingCredentials.Count >= MaxCredentialsPerUser) { - return false; + return null; } var existingCredentialIds = existingCredentials.Select(c => c.CredentialId); IsCredentialIdUniqueToUserAsyncDelegate callback = (args, cancellationToken) => Task.FromResult(!existingCredentialIds.Contains(CoreHelpers.Base64UrlEncode(args.CredentialId))); var success = await _fido2.MakeNewCredentialAsync(attestationResponse, options, callback); + if (success.Result == null) + { + return null; + } var credential = new WebAuthnCredential { @@ -51,6 +52,6 @@ public async Task CreateWebAuthnLoginCredentialAsync(User user, string nam }; await _webAuthnCredentialRepository.CreateAsync(credential); - return true; + return credential; } } diff --git a/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs b/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs index 44ca7910c6ce..fc25b68936c1 100644 --- a/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs @@ -212,7 +212,7 @@ public async Task Post_ExpiredToken_ThrowsBadRequestException(WebAuthnLoginCrede } [Theory, BitAutoData] - public async Task Post_ValidInput_Returns(WebAuthnLoginCredentialCreateRequestModel requestModel, CredentialCreateOptions createOptions, User user, SutProvider sutProvider) + public async Task Post_ValidInput_ReturnsCredential(WebAuthnLoginCredentialCreateRequestModel requestModel, CredentialCreateOptions createOptions, User user, WebAuthnCredential credential, SutProvider sutProvider) { // Arrange var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions); @@ -221,23 +221,41 @@ public async Task Post_ValidInput_Returns(WebAuthnLoginCredentialCreateRequestMo .ReturnsForAnyArgs(user); sutProvider.GetDependency() .CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any(), requestModel.SupportsPrf, requestModel.EncryptedUserKey, requestModel.EncryptedPublicKey, requestModel.EncryptedPrivateKey) - .Returns(true); + .Returns(credential); sutProvider.GetDependency>() .Unprotect(requestModel.Token) .Returns(token); // Act - await sutProvider.Sut.Post(requestModel); + var result = await sutProvider.Sut.Post(requestModel); // Assert - await sutProvider.GetDependency() - .Received(1) - .GetUserByPrincipalAsync(default); + Assert.NotNull(result); + Assert.Equal(credential.Id.ToString(), result.Id); await sutProvider.GetDependency() .Received(1) .CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any(), requestModel.SupportsPrf, requestModel.EncryptedUserKey, requestModel.EncryptedPublicKey, requestModel.EncryptedPrivateKey); } + [Theory, BitAutoData] + public async Task Post_CredentialCreationFailed_ThrowsBadRequestException(WebAuthnLoginCredentialCreateRequestModel requestModel, CredentialCreateOptions createOptions, User user, SutProvider sutProvider) + { + // Arrange + var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions); + sutProvider.GetDependency() + .GetUserByPrincipalAsync(default) + .ReturnsForAnyArgs(user); + sutProvider.GetDependency() + .CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any(), requestModel.SupportsPrf, requestModel.EncryptedUserKey, requestModel.EncryptedPublicKey, requestModel.EncryptedPrivateKey) + .Returns((WebAuthnCredential)null); + sutProvider.GetDependency>() + .Unprotect(requestModel.Token) + .Returns(token); + + // Act & Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.Post(requestModel)); + } + [Theory, BitAutoData] public async Task Post_RequireSsoPolicyApplicable_ThrowsBadRequestException( WebAuthnLoginCredentialCreateRequestModel requestModel, @@ -250,9 +268,6 @@ public async Task Post_RequireSsoPolicyApplicable_ThrowsBadRequestException( sutProvider.GetDependency() .GetUserByPrincipalAsync(default) .ReturnsForAnyArgs(user); - sutProvider.GetDependency() - .CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any(), false) - .Returns(true); sutProvider.GetDependency>() .Unprotect(requestModel.Token) .Returns(token); @@ -269,6 +284,7 @@ public async Task Post_RequireSsoPolicyNotApplicable_Succeeds( WebAuthnLoginCredentialCreateRequestModel requestModel, CredentialCreateOptions createOptions, User user, + WebAuthnCredential credential, SutProvider sutProvider) { // Arrange @@ -278,7 +294,7 @@ public async Task Post_RequireSsoPolicyNotApplicable_Succeeds( .ReturnsForAnyArgs(user); sutProvider.GetDependency() .CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any(), requestModel.SupportsPrf, requestModel.EncryptedUserKey, requestModel.EncryptedPublicKey, requestModel.EncryptedPrivateKey) - .Returns(true); + .Returns(credential); sutProvider.GetDependency>() .Unprotect(requestModel.Token) .Returns(token); @@ -308,9 +324,6 @@ public async Task Post_WithPolicyRequirementsEnabled_CanUsePasskeyLoginFalse_Thr sutProvider.GetDependency() .GetUserByPrincipalAsync(default) .ReturnsForAnyArgs(user); - sutProvider.GetDependency() - .CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any(), false) - .Returns(true); sutProvider.GetDependency>() .Unprotect(requestModel.Token) .Returns(token); @@ -330,6 +343,7 @@ public async Task Post_WithPolicyRequirementsEnabled_CanUsePasskeyLoginTrue_Succ WebAuthnLoginCredentialCreateRequestModel requestModel, CredentialCreateOptions createOptions, User user, + WebAuthnCredential credential, SutProvider sutProvider) { // Arrange @@ -339,7 +353,7 @@ public async Task Post_WithPolicyRequirementsEnabled_CanUsePasskeyLoginTrue_Succ .ReturnsForAnyArgs(user); sutProvider.GetDependency() .CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any(), requestModel.SupportsPrf, requestModel.EncryptedUserKey, requestModel.EncryptedPublicKey, requestModel.EncryptedPrivateKey) - .Returns(true); + .Returns(credential); sutProvider.GetDependency>() .Unprotect(requestModel.Token) .Returns(token); diff --git a/test/Core.Test/Auth/UserFeatures/WebAuthnLogin/CreateWebAuthnLoginCredentialCommandTests.cs b/test/Core.Test/Auth/UserFeatures/WebAuthnLogin/CreateWebAuthnLoginCredentialCommandTests.cs index c7490e8ed6fa..95445539fa1b 100644 --- a/test/Core.Test/Auth/UserFeatures/WebAuthnLogin/CreateWebAuthnLoginCredentialCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/WebAuthnLogin/CreateWebAuthnLoginCredentialCommandTests.cs @@ -27,7 +27,7 @@ internal async Task ExceedsExistingCredentialsLimit_ReturnsFalse(SutProvider().DidNotReceive().CreateAsync(Arg.Any()); } @@ -45,7 +45,7 @@ internal async Task DoesNotExceedExistingCredentialsLimit_CreatesCredential(SutP var result = await sutProvider.Sut.CreateWebAuthnLoginCredentialAsync(user, "name", options, response, false, null, null, null); // Assert - Assert.True(result); + Assert.NotNull(result); await sutProvider.GetDependency().Received().CreateAsync(Arg.Any()); } From 05ce54ddda780a2c848d211e0ed8d0fd3c21ac21 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:00:40 -0500 Subject: [PATCH 13/85] [PM-32594] Add authorization to admin-initiated sponsorship endpoints (#7095) --- .../OrganizationSponsorshipsController.cs | 12 +- ...ostedOrganizationSponsorshipsController.cs | 6 +- ...OrganizationSponsorshipsControllerTests.cs | 439 ++++++++++++++++++ 3 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 test/Api.IntegrationTest/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index 8a1467dfa2fb..7d52a7db60f2 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -1,6 +1,8 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; using Bit.Api.Models.Response.Organizations; @@ -104,9 +106,10 @@ await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _curre } [Authorize("Application")] - [HttpPost("{sponsoringOrgId}/families-for-enterprise/resend")] + [Authorize] + [HttpPost("{organizationId}/families-for-enterprise/resend")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task ResendSponsorshipOffer(Guid sponsoringOrgId, [FromQuery] string sponsoredFriendlyName) + public async Task ResendSponsorshipOffer([FromRoute(Name = "organizationId")] Guid sponsoringOrgId, [FromQuery] string sponsoredFriendlyName) { var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId, PolicyType.FreeFamiliesSponsorshipPolicy); @@ -221,9 +224,10 @@ public async Task PostRevokeSponsorship(Guid sponsoringOrganizationId) } [Authorize("Application")] - [HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")] + [Authorize] + [HttpDelete("{organizationId}/{sponsoredFriendlyName}/revoke")] [SelfHosted(NotSelfHostedOnly = true)] - public async Task AdminInitiatedRevokeSponsorshipAsync(Guid sponsoringOrgId, string sponsoredFriendlyName) + public async Task AdminInitiatedRevokeSponsorshipAsync([FromRoute(Name = "organizationId")] Guid sponsoringOrgId, string sponsoredFriendlyName) { var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId); var existingOrgSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName != null && s.FriendlyName.Equals(sponsoredFriendlyName, StringComparison.OrdinalIgnoreCase)); diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs index 6865bc06dadd..67f21bdf37e0 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs @@ -1,6 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Api.AdminConsole.Authorization; using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Response; @@ -88,8 +89,9 @@ public async Task PostRevokeSponsorship(Guid sponsoringOrgId) await RevokeSponsorship(sponsoringOrgId); } - [HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")] - public async Task AdminInitiatedRevokeSponsorshipAsync(Guid sponsoringOrgId, string sponsoredFriendlyName) + [Authorize] + [HttpDelete("{organizationId}/{sponsoredFriendlyName}/revoke")] + public async Task AdminInitiatedRevokeSponsorshipAsync([FromRoute(Name = "organizationId")] Guid sponsoringOrgId, string sponsoredFriendlyName) { var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId); var existingOrgSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName != null && s.FriendlyName.Equals(sponsoredFriendlyName, StringComparison.OrdinalIgnoreCase)); diff --git a/test/Api.IntegrationTest/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs b/test/Api.IntegrationTest/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs new file mode 100644 index 000000000000..9724fecf7b01 --- /dev/null +++ b/test/Api.IntegrationTest/Billing/Controllers/OrganizationSponsorshipsControllerTests.cs @@ -0,0 +1,439 @@ +using System.Net; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Api.IntegrationTest.Billing.Controllers; + +/// +/// Integration tests for OrganizationSponsorshipsController, focusing on authorization checks +/// for the admin-initiated sponsorship endpoints. +/// +public class OrganizationSponsorshipsControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private Organization _organization = null!; + private OrganizationUser _ownerOrgUser = null!; + private string _ownerEmail = null!; + + public OrganizationSponsorshipsControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + // Create an Enterprise org (required for sponsorship features) + _ownerEmail = $"sponsorship-test-owner-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _ownerOrgUser) = await OrganizationTestHelpers.SignUpAsync( + _factory, + plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, + passwordManagerSeats: 5, + paymentMethod: PaymentMethodType.Card); + + // Enable the AdminSponsoredFamilies feature on the org + var organizationRepository = _factory.GetService(); + _organization.UseAdminSponsoredFamilies = true; + await organizationRepository.ReplaceAsync(_organization); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + /// + /// Reproduces VULN-441: Any authenticated user (not a member of the org) can revoke + /// admin-initiated sponsorships by calling DELETE /{sponsoringOrgId}/{friendlyName}/revoke. + /// This test asserts the CORRECT behavior (should return Forbidden/Unauthorized), + /// and will FAIL until the fix is applied. + /// + [Fact] + public async Task AdminInitiatedRevokeSponsorship_AsNonMember_ReturnsForbidden() + { + // Arrange: Create a sponsorship directly in the DB for the org + var sponsorship = await CreateAdminInitiatedSponsorshipAsync( + _organization.Id, _ownerOrgUser.Id, "victim@example.com"); + + // Create a completely separate user who is NOT a member of the org + var attackerEmail = $"attacker-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(attackerEmail); + await _loginHelper.LoginAsync(attackerEmail); + + // Act: The attacker tries to revoke the sponsorship + var response = await _client.DeleteAsync( + $"organization/sponsorship/{_organization.Id}/{Uri.EscapeDataString(sponsorship.FriendlyName!)}/revoke"); + + // Assert: Should be rejected — non-members must not be able to revoke sponsorships + Assert.True( + response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized, + $"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " + + "Non-org-members should not be able to revoke admin-initiated sponsorships."); + + // Verify the sponsorship still exists (was NOT deleted) + var sponsorshipRepository = _factory.GetService(); + var stillExists = await sponsorshipRepository.GetByIdAsync(sponsorship.Id); + Assert.NotNull(stillExists); + Assert.False(stillExists.ToDelete, "Sponsorship should not have been marked for deletion."); + } + + /// + /// Verifies that a regular member (User type, no special permissions) of the org + /// also cannot revoke admin-initiated sponsorships. + /// + [Fact] + public async Task AdminInitiatedRevokeSponsorship_AsRegularMember_ReturnsForbidden() + { + // Arrange: Create a sponsorship + var sponsorship = await CreateAdminInitiatedSponsorshipAsync( + _organization.Id, _ownerOrgUser.Id, "victim2@example.com"); + + // Create a regular member of the org (User type, no ManageUsers permission) + var (memberEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User, + permissions: new Permissions { ManageUsers = false }); + + await _loginHelper.LoginAsync(memberEmail); + + // Act + var response = await _client.DeleteAsync( + $"organization/sponsorship/{_organization.Id}/{Uri.EscapeDataString(sponsorship.FriendlyName!)}/revoke"); + + // Assert: Regular members without ManageUsers should not be able to revoke + Assert.True( + response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized, + $"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " + + "Regular org members without ManageUsers should not be able to revoke admin-initiated sponsorships."); + + // Verify the sponsorship still exists + var sponsorshipRepository = _factory.GetService(); + var stillExists = await sponsorshipRepository.GetByIdAsync(sponsorship.Id); + Assert.NotNull(stillExists); + Assert.False(stillExists.ToDelete, "Sponsorship should not have been marked for deletion."); + } + + /// + /// Verifies that an org Owner CAN revoke admin-initiated sponsorships (positive test). + /// + [Fact] + public async Task AdminInitiatedRevokeSponsorship_AsOwner_Succeeds() + { + // Arrange: Create a sponsorship + var sponsorship = await CreateAdminInitiatedSponsorshipAsync( + _organization.Id, _ownerOrgUser.Id, "employee@example.com"); + + await _loginHelper.LoginAsync(_ownerEmail); + + // Act + var response = await _client.DeleteAsync( + $"organization/sponsorship/{_organization.Id}/{Uri.EscapeDataString(sponsorship.FriendlyName!)}/revoke"); + + // Assert: Owner should be able to revoke + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Verifies that an org Admin CAN revoke admin-initiated sponsorships. + /// + [Fact] + public async Task AdminInitiatedRevokeSponsorship_AsAdmin_Succeeds() + { + // Arrange + var sponsorship = await CreateAdminInitiatedSponsorshipAsync( + _organization.Id, _ownerOrgUser.Id, "employee-admin@example.com"); + + var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.Admin); + + await _loginHelper.LoginAsync(adminEmail); + + // Act + var response = await _client.DeleteAsync( + $"organization/sponsorship/{_organization.Id}/{Uri.EscapeDataString(sponsorship.FriendlyName!)}/revoke"); + + // Assert: Admin should be able to revoke + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Verifies that a Custom user with ManageUsers permission CAN revoke admin-initiated sponsorships. + /// + [Fact] + public async Task AdminInitiatedRevokeSponsorship_AsCustomWithManageUsers_Succeeds() + { + // Arrange + var sponsorship = await CreateAdminInitiatedSponsorshipAsync( + _organization.Id, _ownerOrgUser.Id, "employee-custom@example.com"); + + var (customEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.Custom, + permissions: new Permissions { ManageUsers = true }); + + await _loginHelper.LoginAsync(customEmail); + + // Act + var response = await _client.DeleteAsync( + $"organization/sponsorship/{_organization.Id}/{Uri.EscapeDataString(sponsorship.FriendlyName!)}/revoke"); + + // Assert: Custom user with ManageUsers should be able to revoke + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Reproduces the cross-org attack: user is admin of Org A but tries to revoke + /// sponsorships of Org B (of which they are NOT a member). + /// + [Fact] + public async Task AdminInitiatedRevokeSponsorship_AsAdminOfDifferentOrg_ReturnsForbidden() + { + // Arrange: Create a sponsorship on the target org + var sponsorship = await CreateAdminInitiatedSponsorshipAsync( + _organization.Id, _ownerOrgUser.Id, "cross-org-victim@example.com"); + + // Create a different org and make the attacker its owner + var attackerEmail = $"other-org-admin-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(attackerEmail); + + var (otherOrg, _) = await OrganizationTestHelpers.SignUpAsync( + _factory, + plan: PlanType.EnterpriseAnnually, + ownerEmail: attackerEmail, + name: "Attacker Org", + billingEmail: attackerEmail, + passwordManagerSeats: 5, + paymentMethod: PaymentMethodType.Card); + + // Log in as the attacker (owner of otherOrg, NOT a member of _organization) + await _loginHelper.LoginAsync(attackerEmail); + + // Act: Try to revoke a sponsorship on the target org + var response = await _client.DeleteAsync( + $"organization/sponsorship/{_organization.Id}/{Uri.EscapeDataString(sponsorship.FriendlyName!)}/revoke"); + + // Assert: Should be rejected — being admin of another org doesn't grant access + Assert.True( + response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized, + $"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " + + "Admin of a different org should not be able to revoke sponsorships of another org."); + + // Verify the sponsorship still exists + var sponsorshipRepository = _factory.GetService(); + var stillExists = await sponsorshipRepository.GetByIdAsync(sponsorship.Id); + Assert.NotNull(stillExists); + Assert.False(stillExists.ToDelete, "Sponsorship should not have been marked for deletion."); + } + + #region ResendSponsorshipOffer authorization tests + + /// + /// Verifies that a non-member cannot trigger sponsorship offer emails + /// for an organization they don't belong to. + /// + [Fact] + public async Task ResendSponsorshipOffer_AsNonMember_ReturnsForbidden() + { + // Arrange + var sponsorship = await CreateAdminInitiatedSponsorshipAsync( + _organization.Id, _ownerOrgUser.Id, "resend-victim@example.com"); + + var attackerEmail = $"resend-attacker-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(attackerEmail); + await _loginHelper.LoginAsync(attackerEmail); + + // Act + var response = await _client.PostAsync( + $"organization/sponsorship/{_organization.Id}/families-for-enterprise/resend?sponsoredFriendlyName={Uri.EscapeDataString(sponsorship.FriendlyName!)}", + null); + + // Assert + Assert.True( + response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized, + $"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " + + "Non-org-members should not be able to resend sponsorship offers."); + } + + /// + /// Verifies that a regular member without ManageUsers cannot resend sponsorship offers. + /// + [Fact] + public async Task ResendSponsorshipOffer_AsRegularMember_ReturnsForbidden() + { + // Arrange + var sponsorship = await CreateAdminInitiatedSponsorshipAsync( + _organization.Id, _ownerOrgUser.Id, "resend-victim2@example.com"); + + var (memberEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User, + permissions: new Permissions { ManageUsers = false }); + + await _loginHelper.LoginAsync(memberEmail); + + // Act + var response = await _client.PostAsync( + $"organization/sponsorship/{_organization.Id}/families-for-enterprise/resend?sponsoredFriendlyName={Uri.EscapeDataString(sponsorship.FriendlyName!)}", + null); + + // Assert + Assert.True( + response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized, + $"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " + + "Regular org members without ManageUsers should not be able to resend sponsorship offers."); + } + + /// + /// Verifies that an admin of a different org cannot resend sponsorship offers + /// for the target org (cross-org attack). + /// + [Fact] + public async Task ResendSponsorshipOffer_AsAdminOfDifferentOrg_ReturnsForbidden() + { + // Arrange + var sponsorship = await CreateAdminInitiatedSponsorshipAsync( + _organization.Id, _ownerOrgUser.Id, "resend-cross-org@example.com"); + + var attackerEmail = $"resend-other-org-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(attackerEmail); + + await OrganizationTestHelpers.SignUpAsync( + _factory, + plan: PlanType.EnterpriseAnnually, + ownerEmail: attackerEmail, + name: "Resend Attacker Org", + billingEmail: attackerEmail, + passwordManagerSeats: 5, + paymentMethod: PaymentMethodType.Card); + + await _loginHelper.LoginAsync(attackerEmail); + + // Act + var response = await _client.PostAsync( + $"organization/sponsorship/{_organization.Id}/families-for-enterprise/resend?sponsoredFriendlyName={Uri.EscapeDataString(sponsorship.FriendlyName!)}", + null); + + // Assert + Assert.True( + response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized, + $"Expected 401 or 403 but got {(int)response.StatusCode} {response.StatusCode}. " + + "Admin of a different org should not be able to resend sponsorship offers for another org."); + } + + /// + /// Verifies that an org Owner CAN resend sponsorship offers. + /// Note: The endpoint may still return a non-200 due to downstream email/policy logic, + /// but crucially it should NOT return 401/403. + /// + [Fact] + public async Task ResendSponsorshipOffer_AsOwner_IsNotForbidden() + { + // Arrange + var sponsorship = await CreateAdminInitiatedSponsorshipAsync( + _organization.Id, _ownerOrgUser.Id, "resend-employee@example.com"); + + await _loginHelper.LoginAsync(_ownerEmail); + + // Act + var response = await _client.PostAsync( + $"organization/sponsorship/{_organization.Id}/families-for-enterprise/resend?sponsoredFriendlyName={Uri.EscapeDataString(sponsorship.FriendlyName!)}", + null); + + // Assert: Should pass authorization (may fail downstream for other reasons, but not 401/403) + Assert.True( + response.StatusCode is not HttpStatusCode.Forbidden and not HttpStatusCode.Unauthorized, + $"Expected to pass authorization but got {(int)response.StatusCode} {response.StatusCode}."); + } + + /// + /// Verifies that an org Admin CAN resend sponsorship offers. + /// + [Fact] + public async Task ResendSponsorshipOffer_AsAdmin_IsNotForbidden() + { + // Arrange + var sponsorship = await CreateAdminInitiatedSponsorshipAsync( + _organization.Id, _ownerOrgUser.Id, "resend-admin@example.com"); + + var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.Admin); + + await _loginHelper.LoginAsync(adminEmail); + + // Act + var response = await _client.PostAsync( + $"organization/sponsorship/{_organization.Id}/families-for-enterprise/resend?sponsoredFriendlyName={Uri.EscapeDataString(sponsorship.FriendlyName!)}", + null); + + // Assert + Assert.True( + response.StatusCode is not HttpStatusCode.Forbidden and not HttpStatusCode.Unauthorized, + $"Expected to pass authorization but got {(int)response.StatusCode} {response.StatusCode}."); + } + + /// + /// Verifies that a Custom user with ManageUsers CAN resend sponsorship offers. + /// + [Fact] + public async Task ResendSponsorshipOffer_AsCustomWithManageUsers_IsNotForbidden() + { + // Arrange + var sponsorship = await CreateAdminInitiatedSponsorshipAsync( + _organization.Id, _ownerOrgUser.Id, "resend-custom@example.com"); + + var (customEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.Custom, + permissions: new Permissions { ManageUsers = true }); + + await _loginHelper.LoginAsync(customEmail); + + // Act + var response = await _client.PostAsync( + $"organization/sponsorship/{_organization.Id}/families-for-enterprise/resend?sponsoredFriendlyName={Uri.EscapeDataString(sponsorship.FriendlyName!)}", + null); + + // Assert + Assert.True( + response.StatusCode is not HttpStatusCode.Forbidden and not HttpStatusCode.Unauthorized, + $"Expected to pass authorization but got {(int)response.StatusCode} {response.StatusCode}."); + } + + #endregion + + /// + /// Helper to create an admin-initiated sponsorship directly in the DB, + /// bypassing the command layer (which has its own auth checks). + /// + private async Task CreateAdminInitiatedSponsorshipAsync( + Guid sponsoringOrgId, Guid sponsoringOrgUserId, string friendlyName) + { + var sponsorshipRepository = _factory.GetService(); + + var sponsorship = new OrganizationSponsorship + { + SponsoringOrganizationId = sponsoringOrgId, + SponsoringOrganizationUserId = sponsoringOrgUserId, + FriendlyName = friendlyName, + OfferedToEmail = friendlyName, + PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise, + IsAdminInitiated = true, + ToDelete = false, + }; + sponsorship.SetNewId(); + + await sponsorshipRepository.CreateAsync(sponsorship); + return sponsorship; + } +} From 2783f2006aeb7fcb8686d37cb8cab9f45af3e7da Mon Sep 17 00:00:00 2001 From: sven-bitwarden Date: Thu, 5 Mar 2026 09:56:02 -0600 Subject: [PATCH 14/85] [PM-28519] Remove Emergency Access Contacts for AutoConfirm Org Flows (#7123) * Remove emergency access from all organization users on policy enable, or when accepted/restored * Use correct policy save system * Add additional tests * Implement both PreUpsert and OnSave side effects --- .../OrganizationUsers/AcceptOrgUserCommand.cs | 24 +++- .../ConfirmOrganizationUserCommand.cs | 18 ++- .../v1/RestoreOrganizationUserCommand.cs | 17 ++- ...rConfirmationPolicyEnforcementValidator.cs | 11 +- ...rConfirmationPolicyEnforcementValidator.cs | 17 +++ ...maticUserConfirmationPolicyEventHandler.cs | 30 +++- .../AcceptOrgUserCommandTests.cs | 76 +++++++++- .../ConfirmOrganizationUserCommandTests.cs | 130 ++++++++++++++++-- .../RestoreOrganizationUserCommandTests.cs | 112 +++++++++++++++ ...UserConfirmationPolicyEventHandlerTests.cs | 72 ++++++++++ 10 files changed, 480 insertions(+), 27 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index d8b66383890d..1b29f379628f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Entities; @@ -33,6 +34,7 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator; private readonly IPushAutoConfirmNotificationCommand _pushAutoConfirmNotificationCommand; + private readonly IDeleteEmergencyAccessCommand _deleteEmergencyAccessCommand; public AcceptOrgUserCommand( IOrganizationUserRepository organizationUserRepository, @@ -45,7 +47,8 @@ public AcceptOrgUserCommand( IFeatureService featureService, IPolicyRequirementQuery policyRequirementQuery, IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator, - IPushAutoConfirmNotificationCommand pushAutoConfirmNotificationCommand) + IPushAutoConfirmNotificationCommand pushAutoConfirmNotificationCommand, + IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand) { _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; @@ -58,6 +61,7 @@ public AcceptOrgUserCommand( _policyRequirementQuery = policyRequirementQuery; _automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator; _pushAutoConfirmNotificationCommand = pushAutoConfirmNotificationCommand; + _deleteEmergencyAccessCommand = deleteEmergencyAccessCommand; } public async Task AcceptOrgUserByEmailTokenAsync(Guid organizationUserId, User user, string emailToken, @@ -171,7 +175,7 @@ public async Task AcceptOrgUserAsync(OrganizationUser orgUser, if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) { - await ValidateAutomaticUserConfirmationPolicyAsync(orgUser, allOrgUsers, user); + await HandleAutomaticUserConfirmationPolicyAsync(orgUser, allOrgUsers, user); } // Enforce Single Organization Policy of organization user is trying to join @@ -248,13 +252,18 @@ private async Task ValidateTwoFactorAuthenticationPolicyAsync(User user, Guid or } } - private async Task ValidateAutomaticUserConfirmationPolicyAsync(OrganizationUser orgUser, - ICollection allOrgUsers, User user) + private async Task HandleAutomaticUserConfirmationPolicyAsync(OrganizationUser orgUser, + ICollection allOrgUsers, + User user) { + var policyRequirement = await _policyRequirementQuery.GetAsync( + user.Id); + var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId, allOrgUsers.Append(orgUser), - user))) + user), + policyRequirement)) .Match( error => error.Message, _ => string.Empty @@ -264,5 +273,10 @@ private async Task ValidateAutomaticUserConfirmationPolicyAsync(OrganizationUser { throw new BadRequestException(error); } + + if (policyRequirement.IsEnabled(orgUser.OrganizationId)) + { + await _deleteEmergencyAccessCommand.DeleteAllByUserIdAsync(user.Id); + } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 24c1c3297634..a4c1bee9427f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Entities; @@ -37,6 +38,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand private readonly ICollectionRepository _collectionRepository; private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator; private readonly ISendOrganizationConfirmationCommand _sendOrganizationConfirmationCommand; + private readonly IDeleteEmergencyAccessCommand _deleteEmergencyAccessCommand; + public ConfirmOrganizationUserCommand( IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, @@ -52,7 +55,8 @@ public ConfirmOrganizationUserCommand( IFeatureService featureService, ICollectionRepository collectionRepository, IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator, - ISendOrganizationConfirmationCommand sendOrganizationConfirmationCommand) + ISendOrganizationConfirmationCommand sendOrganizationConfirmationCommand, + IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -69,6 +73,7 @@ public ConfirmOrganizationUserCommand( _collectionRepository = collectionRepository; _automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator; _sendOrganizationConfirmationCommand = sendOrganizationConfirmationCommand; + _deleteEmergencyAccessCommand = deleteEmergencyAccessCommand; } public async Task ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key, Guid confirmingUserId, string defaultUserCollectionName = null) @@ -194,11 +199,15 @@ private async Task CheckPoliciesAsync(Guid organizationId, User user, if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) { + var policyRequirement = await _policyRequirementQuery.GetAsync( + user.Id); + var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( new AutomaticUserConfirmationPolicyEnforcementRequest( organizationId, userOrgs, - user))) + user), + policyRequirement)) .Match( error => new BadRequestException(error.Message), _ => null @@ -208,6 +217,11 @@ private async Task CheckPoliciesAsync(Guid organizationId, User user, { throw error; } + + if (policyRequirement.IsEnabled(organizationId)) + { + await _deleteEmergencyAccessCommand.DeleteAllByUserIdAsync(user.Id); + } } var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index b0038060ceca..55153ed5c98d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Context; @@ -32,7 +33,8 @@ public class RestoreOrganizationUserCommand( IFeatureService featureService, IPolicyRequirementQuery policyRequirementQuery, ICollectionRepository collectionRepository, - IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator) : IRestoreOrganizationUserCommand + IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator, + IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand) : IRestoreOrganizationUserCommand { public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName) { @@ -363,10 +365,12 @@ private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, boo if (featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) { + var policyRequirement = await policyRequirementQuery.GetAsync( + user.Id); + var validationResult = await automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( - new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId, - allOrgUsers, - user!)); + new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId, allOrgUsers, user!), + policyRequirement); var badRequestException = validationResult.Match( error => new BadRequestException(user.Email + @@ -378,6 +382,11 @@ private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, boo { throw badRequestException; } + + if (policyRequirement.IsEnabled(orgUser.OrganizationId)) + { + await deleteEmergencyAccessCommand.DeleteAllByUserIdAsync(user.Id); + } } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs index e5c980ea2467..37c5168ef0b3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Enforcement/AutoConfirm/AutomaticUserConfirmationPolicyEnforcementValidator.cs @@ -17,6 +17,13 @@ public async Task(request.User.Id); + return await IsCompliantAsync(request, automaticUserConfirmationPolicyRequirement); + } + + public async Task> IsCompliantAsync( + AutomaticUserConfirmationPolicyEnforcementRequest request, + AutomaticUserConfirmationPolicyRequirement policyRequirement) + { var currentOrganizationUser = request.AllOrganizationUsers .FirstOrDefault(x => x.OrganizationId == request.OrganizationId // invited users do not have a userId but will have email @@ -27,7 +34,7 @@ public async Task /// A validation result with the error message if applicable. Task> IsCompliantAsync(AutomaticUserConfirmationPolicyEnforcementRequest request); + + /// + /// Checks if the given user is compliant with the Automatic User Confirmation policy, using the passed-in + /// as the source of truth for the validation request. + /// + /// To be compliant, a user must + /// - not be a member of a provider + /// - not be a member of another organization + /// + /// + /// + /// This uses the validation result pattern to avoid throwing exceptions. + /// + /// A validation result with the error message if applicable. + Task> IsCompliantAsync( + AutomaticUserConfirmationPolicyEnforcementRequest request, + AutomaticUserConfirmationPolicyRequirement policyRequirement); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs index 6896cfaa2252..0c1a5877c056 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs @@ -3,6 +3,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; +using Bit.Core.Repositories; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; @@ -16,8 +18,11 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; ///
  • No provider users exist
  • /// /// -public class AutomaticUserConfirmationPolicyEventHandler(IAutomaticUserConfirmationOrganizationPolicyComplianceValidator validator) - : IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent +public class AutomaticUserConfirmationPolicyEventHandler( + IAutomaticUserConfirmationOrganizationPolicyComplianceValidator validator, + IOrganizationUserRepository organizationUserRepository, + IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand) + : IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent, IOnPolicyPreUpdateEvent { public PolicyType Type => PolicyType.AutomaticUserConfirmation; @@ -42,6 +47,23 @@ public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? curre public async Task ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) => await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy); - public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => - Task.CompletedTask; + public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) + { + await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy); + } + + public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + var isNotEnablingPolicy = policyUpdate is not { Enabled: true }; + var policyAlreadyEnabled = currentPolicy is { Enabled: true }; + if (isNotEnablingPolicy || policyAlreadyEnabled) + { + return; + } + + var orgUsers = await organizationUserRepository.GetManyByOrganizationAsync(policyUpdate.OrganizationId, null); + var orgUserIds = orgUsers.Where(w => w.UserId != null).Select(s => s.UserId!.Value).ToList(); + + await deleteEmergencyAccessCommand.DeleteAllByUserIdsAsync(orgUserIds); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs index 5f80d56efe19..19da1fe988a2 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Entities; @@ -685,17 +686,84 @@ public async Task AcceptOrgUserAsync_WithAutoConfirmIsEnabledAndFailsCompliance_ .Returns(true); sutProvider.GetDependency() - .IsCompliantAsync(Arg.Any()) + .IsCompliantAsync(Arg.Any(), Arg.Any()) .Returns(Invalid( new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser, otherOrgUser], user), new UserCannotBelongToAnotherOrganization())); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([new PolicyDetails { OrganizationId = org.Id }])); + // Act & Assert var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService)); // Should get auto-confirm error Assert.Equal(new UserCannotBelongToAnotherOrganization().Message, exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteAllByUserIdAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserAsync_WithAutoConfirmPolicyEnabled_DeletesEmergencyAccess( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([new PolicyDetails { OrganizationId = org.Id }])); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any(), Arg.Any()) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); + + // Act + await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteAllByUserIdAsync(user.Id); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserAsync_WithAutoConfirmPolicyNotEnabled_DoesNotDeleteEmergencyAccess( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([])); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any(), Arg.Any()) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); + + // Act + await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService); + + // Assert + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteAllByUserIdAsync(Arg.Any()); } // Private helpers ------------------------------------------------------------------------------------------------- @@ -730,9 +798,13 @@ public async Task AcceptOrgUser_WithAutoConfirmFeatureFlagEnabled_SendsPushNotif .Returns(true); sutProvider.GetDependency() - .IsCompliantAsync(Arg.Any()) + .IsCompliantAsync(Arg.Any(), Arg.Any()) .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([new PolicyDetails { OrganizationId = org.Id }])); + await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService); await sutProvider.GetDependency() diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs index 8fe744f1143f..50c7501ccd69 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Entities; @@ -570,8 +571,12 @@ public async Task ConfirmUserAsync_WithAutoConfirmEnabledAndUserBelongsToAnother .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) .Returns(true); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([new PolicyDetails { OrganizationId = org.Id }])); + sutProvider.GetDependency() - .IsCompliantAsync(Arg.Any()) + .IsCompliantAsync(Arg.Any(), Arg.Any()) .Returns(Invalid( new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.Id, [orgUser, otherOrgUser], user), new UserCannotBelongToAnotherOrganization())); @@ -580,6 +585,9 @@ public async Task ConfirmUserAsync_WithAutoConfirmEnabledAndUserBelongsToAnother () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); Assert.Equal(new UserCannotBelongToAnotherOrganization().Message, exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteAllByUserIdAsync(Arg.Any()); } [Theory, BitAutoData] @@ -607,8 +615,12 @@ public async Task ConfirmUserAsync_WithAutoConfirmEnabledForOtherOrg_ThrowsBadRe .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) .Returns(true); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([new PolicyDetails { OrganizationId = org.Id }])); + sutProvider.GetDependency() - .IsCompliantAsync(Arg.Any()) + .IsCompliantAsync(Arg.Any(), Arg.Any()) .Returns(Invalid( new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser, otherOrgUser], user), new OtherOrganizationDoesNotAllowOtherMembership())); @@ -618,6 +630,9 @@ public async Task ConfirmUserAsync_WithAutoConfirmEnabledForOtherOrg_ThrowsBadRe () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); Assert.Equal(new OtherOrganizationDoesNotAllowOtherMembership().Message, exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteAllByUserIdAsync(Arg.Any()); } [Theory, BitAutoData] @@ -643,8 +658,12 @@ public async Task ConfirmUserAsync_WithAutoConfirmEnabledAndUserIsProvider_Throw .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) .Returns(true); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([new PolicyDetails { OrganizationId = org.Id }])); + sutProvider.GetDependency() - .IsCompliantAsync(Arg.Any()) + .IsCompliantAsync(Arg.Any(), Arg.Any()) .Returns(Invalid( new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user), new ProviderUsersCannotJoin())); @@ -654,6 +673,9 @@ public async Task ConfirmUserAsync_WithAutoConfirmEnabledAndUserIsProvider_Throw () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); Assert.Equal(new ProviderUsersCannotJoin().Message, exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteAllByUserIdAsync(Arg.Any()); } [Theory, BitAutoData] @@ -679,8 +701,12 @@ public async Task ConfirmUserAsync_WithAutoConfirmNotApplicable_Succeeds( .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) .Returns(true); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([new PolicyDetails { OrganizationId = org.Id }])); + sutProvider.GetDependency() - .IsCompliantAsync(Arg.Any()) + .IsCompliantAsync(Arg.Any(), Arg.Any()) .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); // Act @@ -693,6 +719,86 @@ await sutProvider.GetDependency() .Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, orgUser.AccessSecretsManager); } + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithAutoConfirmPolicyEnabled_DeletesEmergencyAccess( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + string key, SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([user]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([new PolicyDetails { OrganizationId = org.Id }])); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any(), Arg.Any()) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); + + // Act + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteAllByUserIdAsync(user.Id); + } + + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithAutoConfirmPolicyNotEnabled_DoesNotDeleteEmergencyAccess( + Organization org, OrganizationUser confirmingUser, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + string key, SutProvider sutProvider) + { + // Arrange + org.PlanType = PlanType.EnterpriseAnnually; + orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; + orgUser.UserId = user.Id; + + sutProvider.GetDependency() + .GetManyAsync([]).ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency() + .GetManyByManyUsersAsync([]) + .ReturnsForAnyArgs([orgUser]); + sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); + sutProvider.GetDependency().GetManyAsync([]).ReturnsForAnyArgs([user]); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([])); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any(), Arg.Any()) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); + + // Act + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); + + // Assert + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteAllByUserIdAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task ConfirmUserAsync_WithAutoConfirmValidationBeforeSingleOrgPolicy_ChecksAutoConfirmFirst( Organization org, OrganizationUser confirmingUser, @@ -725,8 +831,12 @@ public async Task ConfirmUserAsync_WithAutoConfirmValidationBeforeSingleOrgPolic .GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) .Returns([singleOrgPolicy]); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([new PolicyDetails { OrganizationId = org.Id }])); + sutProvider.GetDependency() - .IsCompliantAsync(Arg.Any()) + .IsCompliantAsync(Arg.Any(), Arg.Any()) .Returns(Invalid( new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser, otherOrgUser], user), new UserCannotBelongToAnotherOrganization())); @@ -772,16 +882,20 @@ public async Task ConfirmUsersAsync_WithAutoConfirmEnabled_MixedResults( .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) .Returns(true); + sutProvider.GetDependency() + .GetAsync(Arg.Any()) + .Returns(new AutomaticUserConfirmationPolicyRequirement([new PolicyDetails { OrganizationId = org.Id }])); + sutProvider.GetDependency() - .IsCompliantAsync(Arg.Is(r => r.User.Id == user1.Id)) + .IsCompliantAsync(Arg.Is(r => r.User.Id == user1.Id), Arg.Any()) .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser1], user1))); sutProvider.GetDependency() - .IsCompliantAsync(Arg.Is(r => r.User.Id == user2.Id)) + .IsCompliantAsync(Arg.Is(r => r.User.Id == user2.Id), Arg.Any()) .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser2], user2))); sutProvider.GetDependency() - .IsCompliantAsync(Arg.Is(r => r.User.Id == user3.Id)) + .IsCompliantAsync(Arg.Is(r => r.User.Id == user3.Id), Arg.Any()) .Returns(Invalid( new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser3, otherOrgUser], user3), new OtherOrganizationDoesNotAllowOtherMembership())); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index 36c1b496dd45..69d6a6a228ca 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -1,10 +1,13 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; using Bit.Core.Context; @@ -20,6 +23,7 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser; @@ -748,6 +752,114 @@ await sutProvider.GetDependency() .PushSyncOrgKeysAsync(Arg.Any()); } + [Theory, BitAutoData] + public async Task RestoreUser_WithAutoConfirmPolicyEnabled_DeletesEmergencyAccess( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.Email = null; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + var user = new User { Id = organizationUser.UserId!.Value, Email = "test@bitwarden.com" }; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([new PolicyDetails { OrganizationId = organization.Id }])); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any(), Arg.Any()) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(organization.Id, [organizationUser], user))); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteAllByUserIdAsync(user.Id); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithAutoConfirmPolicyNotEnabled_DoesNotDeleteEmergencyAccess( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.Email = null; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + var user = new User { Id = organizationUser.UserId!.Value, Email = "test@bitwarden.com" }; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([])); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any(), Arg.Any()) + .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(organization.Id, [organizationUser], user))); + + // Act + await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); + + // Assert + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteAllByUserIdAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task RestoreUser_WithAutoConfirmNonCompliant_DoesNotDeleteEmergencyAccess( + Organization organization, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, + [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organizationUser.Email = null; + RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + + var user = new User { Id = organizationUser.UserId!.Value, Email = "test@bitwarden.com" }; + sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([new PolicyDetails { OrganizationId = organization.Id }])); + + sutProvider.GetDependency() + .IsCompliantAsync(Arg.Any(), Arg.Any()) + .Returns(Invalid( + new AutomaticUserConfirmationPolicyEnforcementRequest(organization.Id, [organizationUser], user), + new UserCannotBelongToAnotherOrganization())); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); + + Assert.Contains("is not compliant with the automatic user confirmation policy", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteAllByUserIdAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task RestoreUsers_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs index 45d3a0b6eeb9..b8b1c4915002 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs @@ -3,6 +3,9 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Repositories; using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -163,4 +166,73 @@ public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdat // Assert Assert.True(string.IsNullOrEmpty(result)); } + + [Theory, BitAutoData] + public async Task ExecutePreUpsertSideEffectAsync_EnablingPolicy_DeletesEmergencyAccessForAllOrgUsers( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + var userId1 = Guid.NewGuid(); + var userId2 = Guid.NewGuid(); + var orgUsers = new List + { + new() { UserId = userId1, OrganizationId = policyUpdate.OrganizationId }, + new() { UserId = userId2, OrganizationId = policyUpdate.OrganizationId }, + new() { UserId = null, OrganizationId = policyUpdate.OrganizationId } // invited user, no UserId + }; + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId, null) + .Returns(orgUsers); + + var savePolicyModel = new SavePolicyModel(policyUpdate); + + // Act + await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, null); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteAllByUserIdsAsync(Arg.Is>(ids => + ids.Count == 2 && ids.Contains(userId1) && ids.Contains(userId2))); + } + + [Theory, BitAutoData] + public async Task ExecutePreUpsertSideEffectAsync_DisablingPolicy_DoesNotDeleteEmergencyAccess( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + var savePolicyModel = new SavePolicyModel(policyUpdate); + + // Act + await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteAllByUserIdsAsync(Arg.Any>()); + } + + [Theory, BitAutoData] + public async Task ExecutePreUpsertSideEffectAsync_PolicyAlreadyEnabled_DoesNotDeleteEmergencyAccess( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + var savePolicyModel = new SavePolicyModel(policyUpdate); + + // Act + await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .DeleteAllByUserIdsAsync(Arg.Any>()); + } } From 76b702ba9b9b12963e59588efa42314138dba6e9 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:08:40 +0100 Subject: [PATCH 15/85] Add coupon support to invoice preview and subscription creation (#6994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add coupon support to invoice preview and subscription creation * Fix the build lint error * Resolve the initial review comments * fix the failing test * fix the build lint error * Fix the failing test * Resolve the unaddressed issues * Fixed the deconstruction error * Fix the lint issue * Fix the lint error * Fix the lint error * Fix the build lint error * lint error resolved * remove the setting file * rename the variable name validatedCoupon * Remove the owner property * Update OrganizationBillingService tests to align with recent refactoring - Remove GetMetadata tests as method no longer exists - Remove Owner property references from OrganizationSale (removed in d7613365ed) - Update coupon validation to use SubscriptionDiscountRepository instead of SubscriptionDiscountService - Add missing imports for SubscriptionDiscount entities - Rename test for clarity: Finalize_WithNullOwner_SkipsValidation → Finalize_WithCouponOutsideDateRange_IgnoresCouponAndProceeds All tests passing (14/14) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Fix the lint error * Making the owner non nullable * fix the failing unit test * Make the owner nullable * Fix the bug for coupon in Stripe with no audience restrictions(PM-32756) * Return validation message for invalid coupon * Update the valid token message * Fix the failing unit test * Remove the duplicate method * Fix the failing build and test * Resolve the failing test * Add delete of invalid coupon * Add the expired error message * Delete on invalid coupon in stripe * Fix the lint errors * return null if we get exception from stripe * remove the auto-delete change * fix the failing test * Fix the lint build error --------- Co-authored-by: Claude --- .../Controllers/OrganizationsController.cs | 10 +- .../Controllers/PreviewInvoiceController.cs | 10 +- .../VNext/AccountBillingVNextController.cs | 5 +- ...OrganizationSubscriptionPurchaseRequest.cs | 6 +- .../PremiumCloudHostedSubscriptionRequest.cs | 13 +- ...ewPremiumSubscriptionPurchaseTaxRequest.cs | 12 +- .../Commands/PreviewOrganizationTaxCommand.cs | 23 +- .../Organizations/Models/OrganizationSale.cs | 15 +- .../OrganizationSubscriptionPurchase.cs | 1 + .../Services/OrganizationBillingService.cs | 30 +- ...tePremiumCloudHostedSubscriptionCommand.cs | 53 +- .../Commands/PreviewPremiumTaxCommand.cs | 30 +- .../Premium/Models/PremiumPurchasePreview.cs | 7 + .../Models/PremiumSubscriptionPurchase.cs | 11 + .../IUpgradeOrganizationPlanCommand.cs | 2 +- .../UpgradeOrganizationPlanCommand.cs | 22 +- .../OrganizationsControllerTests.cs | 6 +- .../PreviewOrganizationTaxCommandTests.cs | 873 +++++++++++++++++- ...miumCloudHostedSubscriptionCommandTests.cs | 353 ++++++- .../Commands/PreviewPremiumTaxCommandTests.cs | 406 +++++++- .../OrganizationBillingServiceTests.cs | 355 ++++++- .../SubscriptionDiscountServiceTests.cs | 25 + .../UpgradeOrganizationPlanCommandTests.cs | 33 +- 23 files changed, 2201 insertions(+), 100 deletions(-) create mode 100644 src/Core/Billing/Premium/Models/PremiumPurchasePreview.cs create mode 100644 src/Core/Billing/Premium/Models/PremiumSubscriptionPurchase.cs diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index bca5605a8c64..d07a6e7b47e9 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -118,13 +118,13 @@ public async Task PostUpgrade(Guid id, [FromBody] Organiza throw new NotFoundException(); } - var (success, paymentIntentClientSecret) = await upgradeOrganizationPlanCommand.UpgradePlanAsync(id, model.ToOrganizationUpgrade()); + var userId = userService.GetProperUserId(User); - if (model.UseSecretsManager && success) - { - var userId = userService.GetProperUserId(User).Value; + var (success, paymentIntentClientSecret) = await upgradeOrganizationPlanCommand.UpgradePlanAsync(id, model.ToOrganizationUpgrade(), userId); - await TryGrantOwnerAccessToSecretsManagerAsync(id, userId); + if (model.UseSecretsManager && success && userId.HasValue) + { + await TryGrantOwnerAccessToSecretsManagerAsync(id, userId.Value); } return new PaymentResponseModel { Success = success, PaymentIntentClientSecret = paymentIntentClientSecret }; diff --git a/src/Api/Billing/Controllers/PreviewInvoiceController.cs b/src/Api/Billing/Controllers/PreviewInvoiceController.cs index c95845461806..5a2dd0415e9d 100644 --- a/src/Api/Billing/Controllers/PreviewInvoiceController.cs +++ b/src/Api/Billing/Controllers/PreviewInvoiceController.cs @@ -18,11 +18,13 @@ public class PreviewInvoiceController( IPreviewPremiumUpgradeProrationCommand previewPremiumUpgradeProrationCommand) : BaseBillingController { [HttpPost("organizations/subscriptions/purchase")] + [InjectUser] public async Task PreviewOrganizationSubscriptionPurchaseTaxAsync( + [BindNever] User user, [FromBody] PreviewOrganizationSubscriptionPurchaseTaxRequest request) { var (purchase, billingAddress) = request.ToDomain(); - var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress); + var result = await previewOrganizationTaxCommand.Run(user, purchase, billingAddress); return Handle(result.Map(pair => new { pair.Tax, pair.Total })); } @@ -49,11 +51,13 @@ public async Task PreviewOrganizationSubscriptionUpdateTaxAsync( } [HttpPost("premium/subscriptions/purchase")] + [InjectUser] public async Task PreviewPremiumSubscriptionPurchaseTaxAsync( + [BindNever] User user, [FromBody] PreviewPremiumSubscriptionPurchaseTaxRequest request) { - var (purchase, billingAddress) = request.ToDomain(); - var result = await previewPremiumTaxCommand.Run(purchase, billingAddress); + var (preview, billingAddress) = request.ToDomain(); + var result = await previewPremiumTaxCommand.Run(user, preview, billingAddress); return Handle(result.Map(pair => new { pair.Tax, pair.Total })); } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index 45b020c4e136..9facdd9b24de 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -81,9 +81,8 @@ public async Task CreateSubscriptionAsync( [BindNever] User user, [FromBody] PremiumCloudHostedSubscriptionRequest request) { - var (paymentMethod, billingAddress, additionalStorageGb) = request.ToDomain(); - var result = await createPremiumCloudHostedSubscriptionCommand.Run( - user, paymentMethod, billingAddress, additionalStorageGb); + var subscriptionPurchase = request.ToDomain(); + var result = await createPremiumCloudHostedSubscriptionCommand.Run(user, subscriptionPurchase); return Handle(result); } diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs index c678b1966c92..71bc7cc860d3 100644 --- a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs @@ -20,6 +20,9 @@ public record OrganizationSubscriptionPurchaseRequest : IValidatableObject public SecretsManagerPurchaseSelections? SecretsManager { get; set; } + [MaxLength(50)] + public string? Coupon { get; set; } + public OrganizationSubscriptionPurchase ToDomain() => new() { Tier = Tier, @@ -35,7 +38,8 @@ public record OrganizationSubscriptionPurchaseRequest : IValidatableObject Seats = SecretsManager.Seats, AdditionalServiceAccounts = SecretsManager.AdditionalServiceAccounts, Standalone = SecretsManager.Standalone - } : null + } : null, + Coupon = Coupon }; public IEnumerable Validate(ValidationContext validationContext) diff --git a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs index 0f9198fdad17..8978b06242c6 100644 --- a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs +++ b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Models; namespace Bit.Api.Billing.Models.Requests.Premium; @@ -15,8 +16,10 @@ public class PremiumCloudHostedSubscriptionRequest : IValidatableObject [Range(0, 99)] public short AdditionalStorageGb { get; set; } = 0; + [MaxLength(50)] + public string? Coupon { get; set; } - public (PaymentMethod, BillingAddress, short) ToDomain() + public PremiumSubscriptionPurchase ToDomain() { // Check if TokenizedPaymentMethod or NonTokenizedPaymentMethod is provided. var tokenizedPaymentMethod = TokenizedPaymentMethod?.ToDomain(); @@ -28,7 +31,13 @@ public class PremiumCloudHostedSubscriptionRequest : IValidatableObject var billingAddress = BillingAddress.ToDomain(); - return (paymentMethod, billingAddress, AdditionalStorageGb); + return new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = AdditionalStorageGb, + Coupon = Coupon + }; } public IEnumerable Validate(ValidationContext validationContext) diff --git a/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs index d1707cf6de6c..a5fdaea64de0 100644 --- a/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs +++ b/src/Api/Billing/Models/Requests/PreviewInvoice/PreviewPremiumSubscriptionPurchaseTaxRequest.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Models; namespace Bit.Api.Billing.Models.Requests.PreviewInvoice; @@ -13,5 +14,14 @@ public record PreviewPremiumSubscriptionPurchaseTaxRequest [Required] public required MinimalBillingAddressRequest BillingAddress { get; set; } - public (short, BillingAddress) ToDomain() => (AdditionalStorage, BillingAddress.ToDomain()); + [MaxLength(50)] + public string? Coupon { get; set; } + + public (PremiumPurchasePreview, BillingAddress) ToDomain() => ( + new PremiumPurchasePreview + { + AdditionalStorageGb = AdditionalStorage, + Coupon = Coupon + }, + BillingAddress.ToDomain()); } diff --git a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs index 2a5e786c98f8..e06aab7b390c 100644 --- a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs +++ b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs @@ -8,6 +8,7 @@ using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Entities; using Bit.Core.Enums; using Microsoft.Extensions.Logging; using OneOf; @@ -21,6 +22,7 @@ namespace Bit.Core.Billing.Organizations.Commands; public interface IPreviewOrganizationTaxCommand { Task> Run( + User user, OrganizationSubscriptionPurchase purchase, BillingAddress billingAddress); @@ -37,10 +39,12 @@ public interface IPreviewOrganizationTaxCommand public class PreviewOrganizationTaxCommand( ILogger logger, IPricingClient pricingClient, - IStripeAdapter stripeAdapter) + IStripeAdapter stripeAdapter, + ISubscriptionDiscountService subscriptionDiscountService) : BaseBillingCommand(logger), IPreviewOrganizationTaxCommand { public Task> Run( + User user, OrganizationSubscriptionPurchase purchase, BillingAddress billingAddress) => HandleAsync<(decimal, decimal)>(async () => @@ -75,6 +79,8 @@ public class PreviewOrganizationTaxCommand( Quantity = purchase.SecretsManager.Seats } ]); + // System coupon takes precedence for standalone Secrets Manager purchases. + // Any user-provided coupons are ignored in this scenario. options.Discounts = [ new InvoiceDiscountOptions @@ -120,6 +126,21 @@ public class PreviewOrganizationTaxCommand( } } + // Validate coupon and only apply if valid. If invalid, proceed without the discount. + // Only Families plans support user-provided coupons + if (!string.IsNullOrWhiteSpace(purchase.Coupon) && purchase.Tier == ProductTierType.Families) + { + var isValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + user, + purchase.Coupon.Trim(), + DiscountTierType.Families); + + if (isValid) + { + options.Discounts = [new InvoiceDiscountOptions { Coupon = purchase.Coupon.Trim() }]; + } + } + break; } diff --git a/src/Core/Billing/Organizations/Models/OrganizationSale.cs b/src/Core/Billing/Organizations/Models/OrganizationSale.cs index a984d5fe7119..13b89b0d49d1 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationSale.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationSale.cs @@ -3,6 +3,7 @@ using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Tax.Models; +using Bit.Core.Entities; using Bit.Core.Models.Business; namespace Bit.Core.Billing.Organizations.Models; @@ -14,16 +15,19 @@ internal OrganizationSale() { } public void Deconstruct( out Organization organization, out CustomerSetup? customerSetup, - out SubscriptionSetup subscriptionSetup) + out SubscriptionSetup subscriptionSetup, + out User? owner) { organization = Organization; customerSetup = CustomerSetup; subscriptionSetup = SubscriptionSetup; + owner = Owner; } public required Organization Organization { get; init; } public CustomerSetup? CustomerSetup { get; init; } public required SubscriptionSetup SubscriptionSetup { get; init; } + public User? Owner { get; init; } public static OrganizationSale From( Organization organization, @@ -40,16 +44,19 @@ public static OrganizationSale From( { Organization = organization, CustomerSetup = customerSetup, - SubscriptionSetup = subscriptionSetup + SubscriptionSetup = subscriptionSetup, + Owner = signup.Owner }; } public static OrganizationSale From( Organization organization, - OrganizationUpgrade upgrade) => new() + OrganizationUpgrade upgrade, + User? owner) => new() { Organization = organization, - SubscriptionSetup = GetSubscriptionSetup(upgrade) + SubscriptionSetup = GetSubscriptionSetup(upgrade), + Owner = owner }; private static CustomerSetup GetCustomerSetup(OrganizationSignup signup) diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs index 6691d6984814..09bda1dde45f 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs @@ -8,6 +8,7 @@ public record OrganizationSubscriptionPurchase public PlanCadenceType Cadence { get; init; } public required PasswordManagerSelections PasswordManager { get; init; } public SecretsManagerSelections? SecretsManager { get; init; } + public string? Coupon { get; init; } public PlanType PlanType => // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 91817791f887..69d4cc1d9f60 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -30,17 +30,41 @@ public class OrganizationBillingService( IPricingClient pricingClient, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, + ISubscriptionDiscountService subscriptionDiscountService, ITaxService taxService) : IOrganizationBillingService { public async Task Finalize(OrganizationSale sale) { - var (organization, customerSetup, subscriptionSetup) = sale; + var (organization, customerSetup, subscriptionSetup, owner) = sale; + + // Validate coupon and only apply if valid. If invalid, proceed without the discount. + // Validation includes user-specific eligibility checks to ensure the owner has never had premium + // and that this is for a Families subscription. + // Only validate discount if owner is provided (i.e., the user performing the upgrade is an owner). + string? validatedCoupon = null; + if (!string.IsNullOrWhiteSpace(customerSetup?.Coupon) && owner != null) + { + // Only Families plans support user-provided coupons + if (subscriptionSetup.PlanType.GetProductTier() == ProductTierType.Families) + { + var isValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + owner, + customerSetup.Coupon.Trim(), + DiscountTierType.Families); + + if (!isValid) + { + throw new BadRequestException("Discount expired. Please review your cart total and try again"); + } + validatedCoupon = customerSetup.Coupon.Trim(); + } + } var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null ? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType) : await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup); - var subscription = await CreateSubscriptionAsync(organization, customer, subscriptionSetup, customerSetup?.Coupon); + var subscription = await CreateSubscriptionAsync(organization, customer, subscriptionSetup, validatedCoupon); if (subscription.Status is StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active) { @@ -409,7 +433,7 @@ private async Task CreateSubscriptionAsync( { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, Customer = customer.Id, - Discounts = !string.IsNullOrEmpty(coupon) ? [new SubscriptionDiscountOptions { Coupon = coupon }] : null, + Discounts = !string.IsNullOrWhiteSpace(coupon) ? [new SubscriptionDiscountOptions { Coupon = coupon.Trim() }] : null, Items = subscriptionItemOptionsList, Metadata = new Dictionary { diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index 9e130c31f41e..7cf517d4cc01 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -1,9 +1,11 @@ using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Premium.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Models; @@ -36,15 +38,11 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand /// Creates a premium cloud-hosted subscription for the specified user. /// /// The user to create the premium subscription for. Must not yet be a premium user. - /// The tokenized payment method containing the payment type and token for billing. - /// The billing address information required for tax calculation and customer creation. - /// Additional storage in GB beyond the base 1GB included with premium (must be >= 0). + /// The subscription purchase details including payment method, billing address, storage, and optional coupon. /// A billing command result indicating success or failure with appropriate error details. Task> Run( User user, - PaymentMethod paymentMethod, - BillingAddress billingAddress, - short additionalStorageGb); + PremiumSubscriptionPurchase subscriptionPurchase); } public class CreatePremiumCloudHostedSubscriptionCommand( @@ -58,7 +56,8 @@ public class CreatePremiumCloudHostedSubscriptionCommand( ILogger logger, IPricingClient pricingClient, IHasPaymentMethodQuery hasPaymentMethodQuery, - IUpdatePaymentMethodCommand updatePaymentMethodCommand) + IUpdatePaymentMethodCommand updatePaymentMethodCommand, + ISubscriptionDiscountService subscriptionDiscountService) : BaseBillingCommand(logger), ICreatePremiumCloudHostedSubscriptionCommand { private static readonly List _expand = ["tax"]; @@ -66,9 +65,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( public Task> Run( User user, - PaymentMethod paymentMethod, - BillingAddress billingAddress, - short additionalStorageGb) => HandleAsync(async () => + PremiumSubscriptionPurchase subscriptionPurchase) => HandleAsync(async () => { // A "terminal" subscription is one that has ended and cannot be renewed/reactivated. // These are: 'canceled' (user canceled) and 'incomplete_expired' (payment failed and time expired). @@ -81,11 +78,23 @@ public Task> Run( return new BadRequest("Already a premium user."); } - if (additionalStorageGb < 0) + if (subscriptionPurchase.AdditionalStorageGb is < 0) { return new BadRequest("Additional storage must be greater than 0."); } + // Validate coupon if provided. Return error if invalid to prevent charging more than expected. + string? validatedCoupon = null; + if (!string.IsNullOrWhiteSpace(subscriptionPurchase.Coupon)) + { + var isValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(user, subscriptionPurchase.Coupon.Trim(), DiscountTierType.Premium); + if (!isValid) + { + return new BadRequest("Discount expired. Please review your cart total and try again"); + } + validatedCoupon = subscriptionPurchase.Coupon.Trim(); + } + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); Customer? customer; @@ -95,7 +104,7 @@ public Task> Run( */ if (string.IsNullOrEmpty(user.GatewayCustomerId)) { - customer = await CreateCustomerAsync(user, paymentMethod, billingAddress); + customer = await CreateCustomerAsync(user, subscriptionPurchase.PaymentMethod, subscriptionPurchase.BillingAddress); } /* * An existing customer without a payment method starting a new subscription indicates a user who previously @@ -106,9 +115,9 @@ public Task> Run( * Additionally, if this is a resubscribe scenario with a tokenized payment method, we should update the payment method * to ensure the new payment method is used instead of the old one. */ - else if (paymentMethod.IsTokenized && (!await hasPaymentMethodQuery.Run(user) || hasTerminalSubscription)) + else if (subscriptionPurchase.PaymentMethod.IsTokenized && (!await hasPaymentMethodQuery.Run(user) || hasTerminalSubscription)) { - await updatePaymentMethodCommand.Run(user, paymentMethod.AsTokenized, billingAddress); + await updatePaymentMethodCommand.Run(user, subscriptionPurchase.PaymentMethod.AsTokenized, subscriptionPurchase.BillingAddress); customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); } else @@ -116,11 +125,11 @@ public Task> Run( customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); } - customer = await ReconcileBillingLocationAsync(customer, billingAddress); + customer = await ReconcileBillingLocationAsync(customer, subscriptionPurchase.BillingAddress); - var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, additionalStorageGb > 0 ? additionalStorageGb : null); + var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, subscriptionPurchase.AdditionalStorageGb > 0 ? subscriptionPurchase.AdditionalStorageGb : null, validatedCoupon); - paymentMethod.Switch( + subscriptionPurchase.PaymentMethod.Switch( tokenized => { // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault @@ -151,7 +160,7 @@ public Task> Run( user.Gateway = GatewayType.Stripe; user.GatewayCustomerId = customer.Id; user.GatewaySubscriptionId = subscription.Id; - user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + additionalStorageGb); + user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + subscriptionPurchase.AdditionalStorageGb.GetValueOrDefault(0)); user.LicenseKey = CoreHelpers.SecureRandomString(20); user.RevisionDate = DateTime.UtcNow; @@ -297,7 +306,8 @@ private async Task CreateSubscriptionAsync( Guid userId, Customer customer, Pricing.Premium.Plan premiumPlan, - int? storage) + int? storage, + string? validatedCoupon) { var subscriptionItemOptionsList = new List @@ -339,6 +349,11 @@ private async Task CreateSubscriptionAsync( OffSession = true }; + if (!string.IsNullOrWhiteSpace(validatedCoupon)) + { + subscriptionCreateOptions.Discounts = [new SubscriptionDiscountOptions { Coupon = validatedCoupon }]; + } + var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions); if (!usingPayPal) diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs index 07247c83cb9e..402b77fdb37d 100644 --- a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs @@ -1,7 +1,10 @@ using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Entities; using Microsoft.Extensions.Logging; using Stripe; @@ -10,17 +13,20 @@ namespace Bit.Core.Billing.Premium.Commands; public interface IPreviewPremiumTaxCommand { Task> Run( - int additionalStorage, + User user, + PremiumPurchasePreview preview, BillingAddress billingAddress); } public class PreviewPremiumTaxCommand( ILogger logger, IPricingClient pricingClient, - IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IPreviewPremiumTaxCommand + IStripeAdapter stripeAdapter, + ISubscriptionDiscountService subscriptionDiscountService) : BaseBillingCommand(logger), IPreviewPremiumTaxCommand { public Task> Run( - int additionalStorage, + User user, + PremiumPurchasePreview preview, BillingAddress billingAddress) => HandleAsync<(decimal, decimal)>(async () => { @@ -47,15 +53,29 @@ public class PreviewPremiumTaxCommand( } }; - if (additionalStorage > 0) + if (preview.AdditionalStorageGb > 0) { options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions { Price = premiumPlan.Storage.StripePriceId, - Quantity = additionalStorage + Quantity = preview.AdditionalStorageGb }); } + // Validate coupon and only apply if valid. If invalid, proceed without the discount. + if (!string.IsNullOrWhiteSpace(preview.Coupon)) + { + var isValid = await subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + user, + preview.Coupon.Trim(), + DiscountTierType.Premium); + + if (isValid) + { + options.Discounts = [new InvoiceDiscountOptions { Coupon = preview.Coupon.Trim() }]; + } + } + var invoice = await stripeAdapter.CreateInvoicePreviewAsync(options); return GetAmounts(invoice); }); diff --git a/src/Core/Billing/Premium/Models/PremiumPurchasePreview.cs b/src/Core/Billing/Premium/Models/PremiumPurchasePreview.cs new file mode 100644 index 000000000000..6a4716b10bb0 --- /dev/null +++ b/src/Core/Billing/Premium/Models/PremiumPurchasePreview.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Premium.Models; + +public record PremiumPurchasePreview +{ + public short? AdditionalStorageGb { get; init; } + public string? Coupon { get; init; } +} diff --git a/src/Core/Billing/Premium/Models/PremiumSubscriptionPurchase.cs b/src/Core/Billing/Premium/Models/PremiumSubscriptionPurchase.cs new file mode 100644 index 000000000000..dcc712c327fd --- /dev/null +++ b/src/Core/Billing/Premium/Models/PremiumSubscriptionPurchase.cs @@ -0,0 +1,11 @@ +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Core.Billing.Premium.Models; + +public record PremiumSubscriptionPurchase +{ + public required PaymentMethod PaymentMethod { get; init; } + public required BillingAddress BillingAddress { get; init; } + public short? AdditionalStorageGb { get; init; } + public string? Coupon { get; init; } +} diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpgradeOrganizationPlanCommand.cs index 59525242bb83..e9f30afb1037 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/Interface/IUpgradeOrganizationPlanCommand.cs @@ -2,5 +2,5 @@ public interface IUpgradeOrganizationPlanCommand { - Task> UpgradePlanAsync(Guid organizationId, Models.Business.OrganizationUpgrade upgrade); + Task> UpgradePlanAsync(Guid organizationId, Models.Business.OrganizationUpgrade upgrade, Guid? userId = null); } diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 4d041e37e86d..299eee7a6def 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -14,6 +14,7 @@ using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.Business; @@ -40,6 +41,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand private readonly IFeatureService _featureService; private readonly IOrganizationBillingService _organizationBillingService; private readonly IPricingClient _pricingClient; + private readonly IUserRepository _userRepository; public UpgradeOrganizationPlanCommand( IOrganizationUserRepository organizationUserRepository, @@ -55,7 +57,8 @@ public UpgradeOrganizationPlanCommand( IOrganizationService organizationService, IFeatureService featureService, IOrganizationBillingService organizationBillingService, - IPricingClient pricingClient) + IPricingClient pricingClient, + IUserRepository userRepository) { _organizationUserRepository = organizationUserRepository; _collectionRepository = collectionRepository; @@ -71,9 +74,10 @@ public UpgradeOrganizationPlanCommand( _featureService = featureService; _organizationBillingService = organizationBillingService; _pricingClient = pricingClient; + _userRepository = userRepository; } - public async Task> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade) + public async Task> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade, Guid? userId = null) { var organization = await GetOrgById(organizationId); if (organization == null) @@ -230,7 +234,19 @@ await _organizationUserRepository.GetManyByOrganizationAsync(organization.Id, if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { - var sale = OrganizationSale.From(organization, upgrade); + // Check if the user performing the upgrade is an owner of the organization + // This is used for discount validation - discounts only apply if the owner is upgrading + User owner = null; + if (userId.HasValue) + { + var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organization.Id, userId.Value); + if (organizationUser != null && organizationUser.Type == OrganizationUserType.Owner) + { + owner = await _userRepository.GetByIdAsync(organizationUser.UserId.Value); + } + } + + var sale = OrganizationSale.From(organization, upgrade, owner); await _organizationBillingService.Finalize(sale); } else diff --git a/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs index 9a3f57c3dca7..6389dc47ae75 100644 --- a/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/OrganizationsControllerTests.cs @@ -118,7 +118,7 @@ public async Task OrganizationsController_PostUpgrade_NonSMUpgrade_ReturnsCorrec _currentContext.EditSubscription(organizationId).Returns(true); - _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) + _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any(), Arg.Any()) .Returns(new Tuple(success, paymentIntentClientSecret)); var response = await _sut.PostUpgrade(organizationId, model); @@ -141,7 +141,7 @@ public async Task OrganizationsController_PostUpgrade_SMUpgrade_ProvidesAccess_R _currentContext.EditSubscription(organizationId).Returns(true); - _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) + _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any(), Arg.Any()) .Returns(new Tuple(success, paymentIntentClientSecret)); _userService.GetProperUserId(Arg.Any()).Returns(userId); @@ -169,7 +169,7 @@ public async Task OrganizationsController_PostUpgrade_SMUpgrade_NullOrgUser_Retu _currentContext.EditSubscription(organizationId).Returns(true); - _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) + _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any(), Arg.Any()) .Returns(new Tuple(success, paymentIntentClientSecret)); _userService.GetProperUserId(Arg.Any()).Returns(userId); diff --git a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs index 2f278dcd2070..83f072fa3657 100644 --- a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs @@ -5,6 +5,7 @@ using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Entities; using Bit.Core.Test.Billing.Mocks.Plans; using Microsoft.Extensions.Logging; using NSubstitute; @@ -19,11 +20,14 @@ public class PreviewOrganizationTaxCommandTests private readonly ILogger _logger = Substitute.For>(); private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ISubscriptionDiscountService _subscriptionDiscountService = Substitute.For(); private readonly PreviewOrganizationTaxCommand _command; + private readonly User _user; public PreviewOrganizationTaxCommandTests() { - _command = new PreviewOrganizationTaxCommand(_logger, _pricingClient, _stripeAdapter); + _user = new User { Id = Guid.NewGuid(), Email = "test@example.com" }; + _command = new PreviewOrganizationTaxCommand(_logger, _pricingClient, _stripeAdapter, _subscriptionDiscountService); } #region Subscription Purchase @@ -60,7 +64,7 @@ public async Task Run_OrganizationSubscriptionPurchase_SponsoredPasswordManager_ _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(purchase, billingAddress); + var result = await _command.Run(_user, purchase, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -118,7 +122,7 @@ public async Task Run_OrganizationSubscriptionPurchase_StandaloneSecretsManager_ _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(purchase, billingAddress); + var result = await _command.Run(_user, purchase, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -181,7 +185,7 @@ public async Task Run_OrganizationSubscriptionPurchase_StandardPurchaseWithStora _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(purchase, billingAddress); + var result = await _command.Run(_user, purchase, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -242,7 +246,7 @@ public async Task Run_OrganizationSubscriptionPurchase_FamiliesTier_NoSecretsMan _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(purchase, billingAddress); + var result = await _command.Run(_user, purchase, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -294,7 +298,7 @@ public async Task Run_OrganizationSubscriptionPurchase_BusinessUseNonUSCountry_U _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(purchase, billingAddress); + var result = await _command.Run(_user, purchase, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -347,7 +351,7 @@ public async Task Run_OrganizationSubscriptionPurchase_SpanishNIFTaxId_AddsEUVAT _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(purchase, billingAddress); + var result = await _command.Run(_user, purchase, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -370,6 +374,620 @@ await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify coupon is ignored for Teams plans (no discounts applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_EnterpriseWithCoupon_IgnoresCoupon() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 10, + AdditionalStorage = 5, + Sponsored = false + }, + SecretsManager = new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = 8, + AdditionalServiceAccounts = 2, + Standalone = false + }, + Coupon = "ENTERPRISE_DISCOUNT_15" + }; + + var billingAddress = new BillingAddress + { + Country = "CA", + PostalCode = "K1A 0A6" + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 1200 }], + Total = 13200 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(12.00m, tax); + Assert.Equal(132.00m, total); + + // Verify coupon is ignored for Enterprise plans (no discounts applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 4 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 10) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 5) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 8) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 2) && + options.Discounts == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_SponsoredPlanWithCoupon_IgnoresCoupon() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Families, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 6, + AdditionalStorage = 0, + Sponsored = true + }, + Coupon = "TEST_COUPON_IGNORED" + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 500 }], + Total = 5500 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(5.00m, tax); + Assert.Equal(55.00m, total); + + // Verify coupon is ignored for sponsored plans (no discounts applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2021-family-for-enterprise-annually" && + options.SubscriptionDetails.Items[0].Quantity == 1 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_StandaloneSecretsManagerWithCoupon_UsesSystemCoupon() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + SecretsManager = new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = 3, + AdditionalServiceAccounts = 0, + Standalone = true + }, + Coupon = "USER_COUPON_IGNORED" + }; + + var billingAddress = new BillingAddress + { + Country = "CA", + PostalCode = "K1A 0A6" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 750 }], + Total = 8250 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(7.50m, tax); + Assert.Equal(82.50m, total); + + // Verify user coupon is ignored and system coupon (SecretsManagerStandalone) is used instead + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-teams-org-seat-monthly" && item.Quantity == 5) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-teams-seat-monthly" && item.Quantity == 3) && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == CouponIDs.SecretsManagerStandalone)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_EmptyStringCoupon_TreatedAsNull() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = "" + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify empty string coupon is treated same as null (no discounts applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_NullCoupon_NoDiscountApplied() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify null coupon results in no discounts applied + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_WhitespaceOnlyCoupon_TreatedAsNull() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = " " + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + // Whitespace-only strings are now trimmed and treated as null/empty, so no discount is applied + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify whitespace-only coupon is treated as null (no discount applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_TeamsWithCouponWithWhitespace_IgnoresCoupon() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = " TEST_COUPON_20 " + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify coupon is ignored for Teams plans (no discounts applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_TeamsWithLongCoupon_IgnoresCoupon() + { + // Very long coupon string (200 characters) + var longCoupon = new string('A', 200); + + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = longCoupon + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify coupon is ignored for Teams plans (no discounts applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_TeamsWithSpecialCharactersCoupon_IgnoresCoupon() + { + // Coupon with special characters (hyphens, underscores, numbers are common in Stripe coupon IDs) + var specialCoupon = "TEST-COUPON_2024-50%OFF"; + + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = specialCoupon + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify coupon is ignored for Teams plans (no discounts applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_TeamsWithUnicodeCoupon_IgnoresCoupon() + { + // Coupon with unicode characters (though unlikely for real Stripe coupons, tests edge case) + var unicodeCoupon = "TEST-COUPON-2024"; + + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = unicodeCoupon + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify coupon is ignored for Teams plans (no discounts applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts == null)); + } + #endregion #region Subscription Plan Change @@ -1409,4 +2027,245 @@ await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( + _user, + "VALID_FAMILIES_DISCOUNT", + DiscountTierType.Families); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == "VALID_FAMILIES_DISCOUNT")); + } + + [Fact] + public async Task Run_FamiliesOrganizationWithInvalidCoupon_ProceedsWithoutDiscount() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Families, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 6, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = "INVALID_COUPON" + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + _user, + "INVALID_COUPON", + DiscountTierType.Families).Returns(false); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( + _user, + "INVALID_COUPON", + DiscountTierType.Families); + + // Verify invalid coupon is silently ignored (no discount applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" && + options.SubscriptionDetails.Items[0].Quantity == 6 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_TeamsOrganizationWithCoupon_IgnoresCoupon() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = "TEAMS_COUPON" + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(isAnnual: false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify coupon validation was NOT called for Teams (only Families plans use coupons) + await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + + // Verify coupon is ignored for Teams plans (no discounts applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Discounts == null)); + } + + [Fact] + public async Task Run_EnterpriseOrganizationWithCoupon_IgnoresCoupon() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 10, + AdditionalStorage = 0, + Sponsored = false + }, + Coupon = "ENTERPRISE_COUPON" + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new EnterprisePlan(isAnnual: true); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 600 }], + Total = 6600 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(6.00m, tax); + Assert.Equal(66.00m, total); + + // Verify coupon validation was NOT called for Enterprise (only Families plans use coupons) + await _subscriptionDiscountService.DidNotReceive().ValidateDiscountEligibilityForUserAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + + // Verify coupon is ignored for Enterprise plans (no discounts applied) + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-enterprise-org-seat-annually" && + options.SubscriptionDetails.Items[0].Quantity == 10 && + options.Discounts == null)); + } + + #endregion } diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index 77614bc7b636..2ef91d54533b 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -1,10 +1,12 @@ using Bit.Core.Billing; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Premium.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Subscriptions.Models; @@ -38,6 +40,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IHasPaymentMethodQuery _hasPaymentMethodQuery = Substitute.For(); private readonly IUpdatePaymentMethodCommand _updatePaymentMethodCommand = Substitute.For(); + private readonly ISubscriptionDiscountService _subscriptionDiscountService = Substitute.For(); private readonly CreatePremiumCloudHostedSubscriptionCommand _command; public CreatePremiumCloudHostedSubscriptionCommandTests() @@ -68,20 +71,66 @@ public CreatePremiumCloudHostedSubscriptionCommandTests() Substitute.For>(), _pricingClient, _hasPaymentMethodQuery, - _updatePaymentMethodCommand); + _updatePaymentMethodCommand, + _subscriptionDiscountService); } + #region Helper Methods + + private static PremiumSubscriptionPurchase CreateSubscriptionPurchase( + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress, + short additionalStorageGb = 0, + string? coupon = null) + { + return new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = additionalStorageGb, + Coupon = coupon + }; + } + + private static StripeCustomer CreateMockCustomer(string customerId = "cust_123", string country = "US", string postalCode = "12345") + { + var mockCustomer = Substitute.For(); + mockCustomer.Id = customerId; + mockCustomer.Address = new Address { Country = country, PostalCode = postalCode }; + mockCustomer.Metadata = new Dictionary(); + return mockCustomer; + } + + private static StripeSubscription CreateMockActiveSubscription(string subscriptionId = "sub_123") + { + var mockSubscription = Substitute.For(); + mockSubscription.Id = subscriptionId; + mockSubscription.Status = "active"; + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; + return mockSubscription; + } + + #endregion + [Theory, BitAutoData] public async Task Run_UserAlreadyPremium_ReturnsBadRequest( User user, - TokenizedPaymentMethod paymentMethod, - BillingAddress billingAddress) + PremiumSubscriptionPurchase subscriptionPurchase) { // Arrange user.Premium = true; // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT1); @@ -92,14 +141,14 @@ public async Task Run_UserAlreadyPremium_ReturnsBadRequest( [Theory, BitAutoData] public async Task Run_NegativeStorageAmount_ReturnsBadRequest( User user, - TokenizedPaymentMethod paymentMethod, - BillingAddress billingAddress) + PremiumSubscriptionPurchase subscriptionPurchase) { // Arrange user.Premium = false; + subscriptionPurchase = subscriptionPurchase with { AdditionalStorageGb = -1 }; // Act - var result = await _command.Run(user, paymentMethod, billingAddress, -1); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT1); @@ -122,6 +171,14 @@ public async Task Run_ValidPaymentMethodTypes_Card_Success( billingAddress.Country = "US"; billingAddress.PostalCode = "12345"; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; @@ -150,7 +207,7 @@ public async Task Run_ValidPaymentMethodTypes_Card_Success( _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -175,6 +232,14 @@ public async Task Run_ValidPaymentMethodTypes_PayPal_Success( billingAddress.Country = "US"; billingAddress.PostalCode = "12345"; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; @@ -198,7 +263,7 @@ public async Task Run_ValidPaymentMethodTypes_PayPal_Success( _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -231,6 +296,14 @@ public async Task Run_ValidRequestWithAdditionalStorage_Success( billingAddress.PostalCode = "12345"; const short additionalStorage = 2; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = additionalStorage, + Coupon = null + }; + var mockCustomer = Substitute.For(); mockCustomer.Id = "cust_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; @@ -259,7 +332,7 @@ public async Task Run_ValidRequestWithAdditionalStorage_Success( _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, additionalStorage); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -285,6 +358,14 @@ public async Task Run_UserHasExistingGatewayCustomerIdAndPaymentMethod_UsesExist billingAddress.Country = "US"; billingAddress.PostalCode = "12345"; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + var mockCustomer = Substitute.For(); mockCustomer.Id = "existing_customer_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; @@ -313,7 +394,7 @@ public async Task Run_UserHasExistingGatewayCustomerIdAndPaymentMethod_UsesExist _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -336,6 +417,14 @@ public async Task Run_UserPreviouslyPurchasedCreditWithoutPaymentMethod_UpdatesP billingAddress.Country = "US"; billingAddress.PostalCode = "12345"; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + var mockCustomer = Substitute.For(); mockCustomer.Id = "existing_customer_123"; mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; @@ -372,7 +461,7 @@ public async Task Run_UserPreviouslyPurchasedCreditWithoutPaymentMethod_UpdatesP _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -437,8 +526,16 @@ public async Task Run_PayPalWithIncompleteSubscription_SetsPremiumTrue( _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -494,8 +591,16 @@ public async Task Run_NonPayPalWithActiveSubscription_SetsPremiumTrue( _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -550,8 +655,16 @@ public async Task Run_SubscriptionStatusDoesNotMatchPatterns_DoesNotSetPremium( _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -603,8 +716,16 @@ public async Task Run_AccountCredit_WithExistingCustomer_Success( _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); _stripeAdapter.UpdateInvoiceAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -628,8 +749,16 @@ public async Task Run_NonTokenizedPaymentWithoutExistingCustomer_ThrowsBillingEx billingAddress.Country = "US"; billingAddress.PostalCode = "12345"; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0, + Coupon = null + }; + // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var result = await _command.Run(user, subscriptionPurchase); //Assert Assert.True(result.IsT3); // Assuming T3 is the Unhandled result @@ -686,11 +815,19 @@ public async Task Run_WithAdditionalStorage_SetsCorrectMaxStorageGb( ] }; + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = additionalStorage, + Coupon = null + }; + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, additionalStorage); + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -742,7 +879,13 @@ public async Task Run_UserWithCanceledSubscription_AllowsResubscribe( _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0 + }; + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); // Should succeed, not return "Already a premium user" @@ -796,7 +939,13 @@ public async Task Run_UserWithIncompleteExpiredSubscription_AllowsResubscribe( _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0 + }; + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); // Should succeed, not return "Already a premium user" @@ -824,7 +973,13 @@ public async Task Run_UserWithActiveSubscription_PremiumTrue_ReturnsBadRequest( _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId).Returns(existingActiveSubscription); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0 + }; + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT1); @@ -877,7 +1032,13 @@ public async Task Run_SubscriptionFetchThrows_ProceedsWithCreation( _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0 + }; + var result = await _command.Run(user, subscriptionPurchase); // Assert - Should proceed successfully despite the exception Assert.True(result.IsT0); @@ -939,7 +1100,13 @@ public async Task Run_ResubscribeWithTerminalSubscription_UpdatesPaymentMethod( _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(newSubscription); // Act - var result = await _command.Run(user, paymentMethod, billingAddress, 0); + var subscriptionPurchase = new PremiumSubscriptionPurchase + { + PaymentMethod = paymentMethod, + BillingAddress = billingAddress, + AdditionalStorageGb = 0 + }; + var result = await _command.Run(user, subscriptionPurchase); // Assert Assert.True(result.IsT0); @@ -949,4 +1116,144 @@ public async Task Run_ResubscribeWithTerminalSubscription_UpdatesPaymentMethod( await _userService.Received(1).SaveUserAsync(user); } + [Theory, BitAutoData] + public async Task Run_ValidCoupon_AppliesCouponSuccessfully( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + + var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupon: "VALID_COUPON"); + var mockCustomer = CreateMockCustomer(); + var mockSubscription = CreateMockActiveSubscription(); + + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(user, "VALID_COUPON", DiscountTierType.Premium) + .Returns(true); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + // Act + var result = await _command.Run(user, subscriptionPurchase); + + // Assert + Assert.True(result.IsT0); + await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(user, "VALID_COUPON", DiscountTierType.Premium); + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(opts => + opts.Discounts != null && + opts.Discounts.Count == 1 && + opts.Discounts[0].Coupon == "VALID_COUPON")); + await _userService.Received(1).SaveUserAsync(user); + await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); + } + + [Theory, BitAutoData] + public async Task Run_InvalidCoupon_ReturnsBadRequest( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + + var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupon: "INVALID_COUPON"); + + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(user, "INVALID_COUPON", DiscountTierType.Premium) + .Returns(false); + + // Act + var result = await _command.Run(user, subscriptionPurchase); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Discount expired. Please review your cart total and try again", badRequest.Response); + await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(user, "INVALID_COUPON", DiscountTierType.Premium); + await _stripeAdapter.DidNotReceive().CreateSubscriptionAsync(Arg.Any()); + await _userService.DidNotReceive().SaveUserAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncVaultAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Run_UserNotEligibleForCoupon_ReturnsBadRequest( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = "existing_customer_123"; + user.GatewaySubscriptionId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + + var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupon: "NEW_USER_ONLY_COUPON"); + + // User has previous subscriptions, so they're not eligible + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(user, "NEW_USER_ONLY_COUPON", DiscountTierType.Premium) + .Returns(false); + + // Act + var result = await _command.Run(user, subscriptionPurchase); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Discount expired. Please review your cart total and try again", badRequest.Response); + await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(user, "NEW_USER_ONLY_COUPON", DiscountTierType.Premium); + await _stripeAdapter.DidNotReceive().CreateSubscriptionAsync(Arg.Any()); + await _userService.DidNotReceive().SaveUserAsync(Arg.Any()); + await _pushNotificationService.DidNotReceive().PushSyncVaultAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Run_CouponWithWhitespace_TrimsCouponCode( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + + var subscriptionPurchase = CreateSubscriptionPurchase(paymentMethod, billingAddress, coupon: " WHITESPACE_COUPON "); + var mockCustomer = CreateMockCustomer(); + var mockSubscription = CreateMockActiveSubscription(); + + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync(user, "WHITESPACE_COUPON", DiscountTierType.Premium) + .Returns(true); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CreateSubscriptionAsync(Arg.Any()).Returns(mockSubscription); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + // Act + var result = await _command.Run(user, subscriptionPurchase); + + // Assert + Assert.True(result.IsT0); + // Verify the coupon was trimmed before validation + await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync(user, "WHITESPACE_COUPON", DiscountTierType.Premium); + // Verify the coupon was trimmed before passing to Stripe + await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is(opts => + opts.Discounts != null && + opts.Discounts.Count == 1 && + opts.Discounts[0].Coupon == "WHITESPACE_COUPON")); + } + } diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs index b5afaf65cd12..1a06dc90cbbb 100644 --- a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs @@ -1,7 +1,10 @@ -using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Premium.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Entities; using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; @@ -17,7 +20,9 @@ public class PreviewPremiumTaxCommandTests private readonly ILogger _logger = Substitute.For>(); private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ISubscriptionDiscountService _subscriptionDiscountService = Substitute.For(); private readonly PreviewPremiumTaxCommand _command; + private readonly User _user; public PreviewPremiumTaxCommandTests() { @@ -32,9 +37,33 @@ public PreviewPremiumTaxCommandTests() }; _pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan); - _command = new PreviewPremiumTaxCommand(_logger, _pricingClient, _stripeAdapter); + _user = new User { Id = Guid.NewGuid(), Email = "test@example.com" }; + + _command = new PreviewPremiumTaxCommand(_logger, _pricingClient, _stripeAdapter, _subscriptionDiscountService); + } + + #region Helper Methods + + private static PremiumPurchasePreview CreatePreview(short additionalStorageGb = 0, string? coupon = null) + { + return new PremiumPurchasePreview + { + AdditionalStorageGb = additionalStorageGb, + Coupon = coupon + }; + } + + private static BillingAddress CreateBillingAddress(string country = "US", string postalCode = "12345") + { + return new BillingAddress + { + Country = country, + PostalCode = postalCode + }; } + #endregion + [Fact] public async Task Run_PremiumWithoutStorage_ReturnsCorrectTaxAmounts() { @@ -52,7 +81,13 @@ public async Task Run_PremiumWithoutStorage_ReturnsCorrectTaxAmounts() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(0, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 0, + Coupon = null + }; + + var result = await _command.Run(_user, preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -86,7 +121,13 @@ public async Task Run_PremiumWithAdditionalStorage_ReturnsCorrectTaxAmounts() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(5, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 5, + Coupon = null + }; + + var result = await _command.Run(_user, preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -122,7 +163,13 @@ public async Task Run_PremiumWithZeroStorage_ExcludesStorageFromItems() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(0, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 0, + Coupon = null + }; + + var result = await _command.Run(_user, preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -156,7 +203,13 @@ public async Task Run_PremiumWithLargeStorage_HandlesMultipleStorageUnits() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(20, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 20, + Coupon = null + }; + + var result = await _command.Run(_user, preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -192,7 +245,13 @@ public async Task Run_PremiumInternationalAddress_UsesCorrectAddressInfo() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(10, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 10, + Coupon = null + }; + + var result = await _command.Run(_user, preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -228,7 +287,13 @@ public async Task Run_PremiumNoTax_ReturnsZeroTax() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(0, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 0, + Coupon = null + }; + + var result = await _command.Run(_user, preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -262,7 +327,13 @@ public async Task Run_NegativeStorage_TreatedAsZero() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(-5, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = -5, + Coupon = null + }; + + var result = await _command.Run(_user, preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; @@ -297,11 +368,326 @@ public async Task Run_AmountConversion_CorrectlyConvertsStripeAmounts() _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); - var result = await _command.Run(0, billingAddress); + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 0, + Coupon = null + }; + + var result = await _command.Run(_user, preview, billingAddress); Assert.True(result.IsT0); var (tax, total) = result.AsT0; Assert.Equal(1.23m, tax); Assert.Equal(31.23m, total); } + + [Fact] + public async Task Run_WithValidCoupon_IncludesCouponInInvoicePreview() + { + var billingAddress = CreateBillingAddress(); + var preview = CreatePreview(coupon: "VALID_COUPON_CODE"); + + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + _user, + "VALID_COUPON_CODE", + DiscountTierType.Premium).Returns(true); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, preview, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == "VALID_COUPON_CODE" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_WithCouponAndStorage_IncludesBothInInvoicePreview() + { + var billingAddress = CreateBillingAddress(country: "CA", postalCode: "K1A 0A6"); + var preview = CreatePreview(additionalStorageGb: 5, coupon: "STORAGE_DISCOUNT"); + + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + _user, + "STORAGE_DISCOUNT", + DiscountTierType.Premium).Returns(true); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 450 }], + Total = 4950 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, preview, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(4.50m, tax); + Assert.Equal(49.50m, total); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == "STORAGE_DISCOUNT" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.PremiumAnnually && item.Quantity == 1) && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.StoragePlanPersonal && item.Quantity == 5))); + } + + [Fact] + public async Task Run_WithCouponWhitespace_TrimsCouponCode() + { + var billingAddress = CreateBillingAddress(country: "GB", postalCode: "SW1A 1AA"); + var preview = CreatePreview(coupon: " WHITESPACE_COUPON "); + + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + _user, + "WHITESPACE_COUPON", + DiscountTierType.Premium).Returns(true); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 250 }], + Total = 2750 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, preview, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(2.50m, tax); + Assert.Equal(27.50m, total); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == "WHITESPACE_COUPON" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_WithNullCoupon_ExcludesCouponFromInvoicePreview() + { + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 0, + Coupon = null + }; + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, preview, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.Discounts == null && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_WithEmptyCoupon_ExcludesCouponFromInvoicePreview() + { + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var preview = new PremiumPurchasePreview + { + AdditionalStorageGb = 0, + Coupon = "" + }; + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, preview, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.Discounts == null && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_WithValidCoupon_ValidatesCouponAndAppliesDiscount() + { + var billingAddress = CreateBillingAddress(); + var preview = CreatePreview(coupon: "VALID_DISCOUNT"); + + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + _user, + "VALID_DISCOUNT", + DiscountTierType.Premium).Returns(true); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 200 }], + Total = 2200 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, preview, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(2.00m, tax); + Assert.Equal(22.00m, total); + + await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( + _user, + "VALID_DISCOUNT", + DiscountTierType.Premium); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == "VALID_DISCOUNT")); + } + + [Fact] + public async Task Run_WithInvalidCoupon_IgnoresCouponAndProceeds() + { + var billingAddress = CreateBillingAddress(); + var preview = CreatePreview(coupon: "INVALID_COUPON"); + + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + _user, + "INVALID_COUPON", + DiscountTierType.Premium).Returns(false); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, preview, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( + _user, + "INVALID_COUPON", + DiscountTierType.Premium); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.Discounts == null || options.Discounts.Count == 0)); + } + + [Fact] + public async Task Run_WithCouponForUserWithPreviousSubscription_IgnoresCouponAndProceeds() + { + var billingAddress = CreateBillingAddress(); + var preview = CreatePreview(coupon: "NEW_USER_ONLY"); + + // User has previous subscription, so validation fails + _subscriptionDiscountService.ValidateDiscountEligibilityForUserAsync( + _user, + "NEW_USER_ONLY", + DiscountTierType.Premium).Returns(false); + + var invoice = new Invoice + { + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], + Total = 3300 + }; + + _stripeAdapter.CreateInvoicePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(_user, preview, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + await _subscriptionDiscountService.Received(1).ValidateDiscountEligibilityForUserAsync( + _user, + "NEW_USER_ONLY", + DiscountTierType.Premium); + + await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(Arg.Is(options => + options.Discounts == null || options.Discounts.Count == 0)); + } } diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index f72c8cdf766a..b44278acc47e 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -7,6 +7,8 @@ using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; @@ -20,11 +22,13 @@ namespace Bit.Core.Test.Billing.Services; [SutProviderCustomize] public class OrganizationBillingServiceTests { + #region Finalize - Trial Settings [Theory, BitAutoData] public async Task NoPaymentMethodAndTrialPeriod_SetsMissingPaymentMethodCancelBehavior( Organization organization, + User owner, SutProvider sutProvider) { // Arrange @@ -49,7 +53,8 @@ public async Task NoPaymentMethodAndTrialPeriod_SetsMissingPaymentMethodCancelBe var sale = new OrganizationSale { Organization = organization, - SubscriptionSetup = subscriptionSetup + SubscriptionSetup = subscriptionSetup, + Owner = owner }; sutProvider.GetDependency() @@ -101,6 +106,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task NoPaymentMethodButNoTrial_DoesNotSetMissingPaymentMethodBehavior( Organization organization, + User owner, SutProvider sutProvider) { // Arrange @@ -125,7 +131,8 @@ public async Task NoPaymentMethodButNoTrial_DoesNotSetMissingPaymentMethodBehavi var sale = new OrganizationSale { Organization = organization, - SubscriptionSetup = subscriptionSetup + SubscriptionSetup = subscriptionSetup, + Owner = owner }; sutProvider.GetDependency() @@ -175,6 +182,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task HasPaymentMethodAndTrialPeriod_DoesNotSetMissingPaymentMethodBehavior( Organization organization, + User owner, SutProvider sutProvider) { // Arrange @@ -199,7 +207,8 @@ public async Task HasPaymentMethodAndTrialPeriod_DoesNotSetMissingPaymentMethodB var sale = new OrganizationSale { Organization = organization, - SubscriptionSetup = subscriptionSetup + SubscriptionSetup = subscriptionSetup, + Owner = owner }; sutProvider.GetDependency() @@ -248,6 +257,346 @@ await sutProvider.GetDependency() #endregion + #region Finalize - Coupon Validation + + [Theory, BitAutoData] + public async Task Finalize_WithValidCoupon_SuccessfullyCreatesSubscription( + Organization organization, + User owner, + SutProvider sutProvider) + { + // Arrange + var plan = MockPlans.Get(PlanType.FamiliesAnnually); + organization.PlanType = PlanType.FamiliesAnnually; + organization.GatewayCustomerId = "cus_test123"; + organization.GatewaySubscriptionId = null; + + var customerSetup = new CustomerSetup + { + Coupon = "VALID_COUPON" + }; + + var subscriptionSetup = new SubscriptionSetup + { + PlanType = PlanType.FamiliesAnnually, + PasswordManagerOptions = new SubscriptionSetup.PasswordManager + { + Seats = 5, + Storage = null, + PremiumAccess = false + }, + SecretsManagerOptions = null, + SkipTrial = false + }; + + var sale = new OrganizationSale + { + Organization = organization, + CustomerSetup = customerSetup, + SubscriptionSetup = subscriptionSetup, + Owner = owner + }; + + sutProvider.GetDependency() + .GetPlanOrThrow(PlanType.FamiliesAnnually) + .Returns(plan); + + sutProvider.GetDependency() + .ValidateDiscountEligibilityForUserAsync( + owner, + "VALID_COUPON", + DiscountTierType.Families) + .Returns(true); + + sutProvider.GetDependency() + .Run(organization) + .Returns(true); + + var customer = new Customer + { + Id = "cus_test123", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow(organization, Arg.Any()) + .Returns(customer); + + sutProvider.GetDependency() + .CreateSubscriptionAsync(Arg.Any()) + .Returns(new Subscription + { + Id = "sub_test123", + Status = StripeConstants.SubscriptionStatus.Active + }); + + sutProvider.GetDependency() + .ReplaceAsync(organization) + .Returns(Task.CompletedTask); + + // Act + await sutProvider.Sut.Finalize(sale); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .ValidateDiscountEligibilityForUserAsync( + owner, + "VALID_COUPON", + DiscountTierType.Families); + + await sutProvider.GetDependency() + .Received(1) + .CreateSubscriptionAsync(Arg.Is(opts => + opts.Discounts != null && opts.Discounts.Count == 1 && opts.Discounts[0].Coupon == "VALID_COUPON")); + } + + [Theory, BitAutoData] + public async Task Finalize_WithInvalidCoupon_ThrowsBadRequestException( + Organization organization, + User owner, + SutProvider sutProvider) + { + // Arrange + var plan = MockPlans.Get(PlanType.FamiliesAnnually); + organization.PlanType = PlanType.FamiliesAnnually; + organization.GatewayCustomerId = "cus_test123"; + organization.GatewaySubscriptionId = null; + + var customerSetup = new CustomerSetup + { + Coupon = "INVALID_COUPON" + }; + + var subscriptionSetup = new SubscriptionSetup + { + PlanType = PlanType.FamiliesAnnually, + PasswordManagerOptions = new SubscriptionSetup.PasswordManager + { + Seats = 5, + Storage = null, + PremiumAccess = false + }, + SecretsManagerOptions = null, + SkipTrial = false + }; + + var sale = new OrganizationSale + { + Organization = organization, + CustomerSetup = customerSetup, + SubscriptionSetup = subscriptionSetup, + Owner = owner + }; + + sutProvider.GetDependency() + .GetPlanOrThrow(PlanType.FamiliesAnnually) + .Returns(plan); + + // Return false to simulate invalid coupon + sutProvider.GetDependency() + .ValidateDiscountEligibilityForUserAsync( + owner, + "INVALID_COUPON", + DiscountTierType.Families) + .Returns(false); + + sutProvider.GetDependency() + .Run(organization) + .Returns(true); + + var customer = new Customer + { + Id = "cus_test123", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow(organization, Arg.Any()) + .Returns(customer); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.Finalize(sale)); + Assert.Equal("Discount expired. Please review your cart total and try again", exception.Message); + + await sutProvider.GetDependency() + .Received(1) + .ValidateDiscountEligibilityForUserAsync( + owner, + "INVALID_COUPON", + DiscountTierType.Families); + + // Verify subscription was NOT created + await sutProvider.GetDependency() + .DidNotReceive() + .CreateSubscriptionAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Finalize_WithNullCoupon_SkipsValidation( + Organization organization, + User owner, + SutProvider sutProvider) + { + // Arrange + var plan = MockPlans.Get(PlanType.TeamsAnnually); + organization.PlanType = PlanType.TeamsAnnually; + organization.GatewayCustomerId = "cus_test123"; + organization.GatewaySubscriptionId = null; + + var customerSetup = new CustomerSetup + { + Coupon = null + }; + + var subscriptionSetup = new SubscriptionSetup + { + PlanType = PlanType.TeamsAnnually, + PasswordManagerOptions = new SubscriptionSetup.PasswordManager + { + Seats = 5, + Storage = null, + PremiumAccess = false + }, + SecretsManagerOptions = null, + SkipTrial = false + }; + + var sale = new OrganizationSale + { + Organization = organization, + CustomerSetup = customerSetup, + SubscriptionSetup = subscriptionSetup, + Owner = owner + }; + + sutProvider.GetDependency() + .GetPlanOrThrow(PlanType.TeamsAnnually) + .Returns(plan); + + sutProvider.GetDependency() + .Run(organization) + .Returns(true); + + var customer = new Customer + { + Id = "cus_test123", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow(organization, Arg.Any()) + .Returns(customer); + + sutProvider.GetDependency() + .CreateSubscriptionAsync(Arg.Any()) + .Returns(new Subscription + { + Id = "sub_test123", + Status = StripeConstants.SubscriptionStatus.Active + }); + + sutProvider.GetDependency() + .ReplaceAsync(organization) + .Returns(Task.CompletedTask); + + // Act + await sutProvider.Sut.Finalize(sale); + + // Assert - Validation should NOT be called + await sutProvider.GetDependency() + .DidNotReceive() + .ValidateDiscountEligibilityForUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + // Subscription should still be created + await sutProvider.GetDependency() + .Received(1) + .CreateSubscriptionAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Finalize_WithCouponOutsideDateRange_ThrowsBadRequestException( + Organization organization, + User owner, + SutProvider sutProvider) + { + // Arrange + var plan = MockPlans.Get(PlanType.FamiliesAnnually); + organization.PlanType = PlanType.FamiliesAnnually; + organization.GatewayCustomerId = "cus_test123"; + organization.GatewaySubscriptionId = null; + + var customerSetup = new CustomerSetup + { + Coupon = "EXPIRED_COUPON" + }; + + var subscriptionSetup = new SubscriptionSetup + { + PlanType = PlanType.FamiliesAnnually, + PasswordManagerOptions = new SubscriptionSetup.PasswordManager + { + Seats = 5, + Storage = null, + PremiumAccess = false + }, + SecretsManagerOptions = null, + SkipTrial = false + }; + + var sale = new OrganizationSale + { + Organization = organization, + CustomerSetup = customerSetup, + SubscriptionSetup = subscriptionSetup, + Owner = owner + }; + + sutProvider.GetDependency() + .GetPlanOrThrow(PlanType.FamiliesAnnually) + .Returns(plan); + + // Return false to simulate expired coupon (outside valid date range) + sutProvider.GetDependency() + .ValidateDiscountEligibilityForUserAsync( + owner, + "EXPIRED_COUPON", + DiscountTierType.Families) + .Returns(false); + + sutProvider.GetDependency() + .Run(organization) + .Returns(true); + + var customer = new Customer + { + Id = "cus_test123", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow(organization, Arg.Any()) + .Returns(customer); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.Finalize(sale)); + Assert.Equal("Discount expired. Please review your cart total and try again", exception.Message); + + await sutProvider.GetDependency() + .Received(1) + .ValidateDiscountEligibilityForUserAsync( + owner, + "EXPIRED_COUPON", + DiscountTierType.Families); + + // Verify subscription was NOT created + await sutProvider.GetDependency() + .DidNotReceive() + .CreateSubscriptionAsync(Arg.Any()); + } + + #endregion + [Theory, BitAutoData] public async Task UpdateOrganizationNameAndEmail_UpdatesStripeCustomer( Organization organization, diff --git a/test/Core.Test/Billing/Services/SubscriptionDiscountServiceTests.cs b/test/Core.Test/Billing/Services/SubscriptionDiscountServiceTests.cs index 3dba2865f823..1a6f9f687444 100644 --- a/test/Core.Test/Billing/Services/SubscriptionDiscountServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriptionDiscountServiceTests.cs @@ -248,4 +248,29 @@ public async Task ValidateDiscountEligibilityForUserAsync_CouponFound_UserIsNotE // Assert Assert.False(result); } + + [Theory, BitAutoData] + public async Task ValidateDiscountEligibilityForUserAsync_InactiveDiscount_ReturnsFalse( + User user, + SubscriptionDiscount discount, + SutProvider sutProvider) + { + // Arrange + discount.StartDate = DateTime.UtcNow.AddDays(-30); + discount.EndDate = DateTime.UtcNow.AddDays(-1); // Expired discount + + sutProvider.GetDependency() + .GetByStripeCouponIdAsync(discount.StripeCouponId) + .Returns(discount); + + // Act + var result = await sutProvider.Sut.ValidateDiscountEligibilityForUserAsync(user, discount.StripeCouponId, DiscountTierType.Premium); + + // Assert + Assert.False(result); + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteAsync(discount); + } + } diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs index b4f1fe2d98c5..87ab30e7798b 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs @@ -1,9 +1,12 @@ -using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Models.Business; @@ -19,13 +22,28 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; -using Organization = Bit.Core.AdminConsole.Entities.Organization; namespace Bit.Core.Test.OrganizationFeatures.OrganizationSubscriptionUpdate; [SutProviderCustomize] public class UpgradeOrganizationPlanCommandTests { + private static void SetupOrganizationOwner(SutProvider sutProvider, Organization organization, User owner) + { + var ownerOrganizationUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = owner.Id, + Type = OrganizationUserType.Owner + }; + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organization.Id, OrganizationUserType.Owner) + .Returns(new[] { ownerOrganizationUser }); + sutProvider.GetDependency() + .GetByIdAsync(owner.Id) + .Returns(owner); + } + [Theory, BitAutoData] public async Task UpgradePlan_OrganizationIsNull_Throws(Guid organizationId, OrganizationUpgrade upgrade, SutProvider sutProvider) @@ -76,9 +94,11 @@ public async Task UpgradePlan_SM_AlreadyInPlan_Throws(Organization organization, [Theory] [FreeOrganizationUpgradeCustomize, BitAutoData] public async Task UpgradePlan_Passes(Organization organization, OrganizationUpgrade upgrade, + User owner, [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { + SetupOrganizationOwner(sutProvider, organization, owner); sutProvider.GetDependency() .RunAsync(Arg.Any(), Arg.Any()) .Returns(policy); @@ -153,10 +173,11 @@ await sutProvider.GetDependency().Received(1).AdjustSubsc [BitAutoData(PlanType.TeamsAnnually)] [BitAutoData(PlanType.TeamsStarter)] public async Task UpgradePlan_SM_Passes(PlanType planType, Organization organization, OrganizationUpgrade upgrade, + User owner, [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { - + SetupOrganizationOwner(sutProvider, organization, owner); upgrade.Plan = planType; sutProvider.GetDependency().GetPlanOrThrow(upgrade.Plan).Returns(MockPlans.Get(upgrade.Plan)); @@ -277,11 +298,13 @@ public async Task UpgradePlan_SM_NotEnoughServiceAccounts_Throws(PlanType planTy public async Task UpgradePlan_WhenOrganizationIsMissingPublicAndPrivateKeys_Backfills( Organization organization, OrganizationUpgrade upgrade, + User owner, string newPublicKey, string newPrivateKey, [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { + SetupOrganizationOwner(sutProvider, organization, owner); organization.PublicKey = null; organization.PrivateKey = null; @@ -323,9 +346,11 @@ await sutProvider.GetDependency() public async Task UpgradePlan_WhenOrganizationAlreadyHasPublicAndPrivateKeys_DoesNotOverwriteWithNull( Organization organization, OrganizationUpgrade upgrade, + User owner, [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { + SetupOrganizationOwner(sutProvider, organization, owner); // Arrange const string existingPublicKey = "existing-public-key"; const string existingPrivateKey = "existing-private-key"; @@ -369,9 +394,11 @@ await sutProvider.GetDependency() public async Task UpgradePlan_WhenOrganizationAlreadyHasPublicAndPrivateKeys_DoesNotBackfillWithNewKeys( Organization organization, OrganizationUpgrade upgrade, + User owner, [Policy(PolicyType.ResetPassword, false)] PolicyStatus policy, SutProvider sutProvider) { + SetupOrganizationOwner(sutProvider, organization, owner); // Arrange const string existingPublicKey = "existing-public-key"; const string existingPrivateKey = "existing-private-key"; From 185a3599ba7650a4a87e79d4eff4a5a196c3dc23 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:47:08 -0500 Subject: [PATCH 16/85] [PM-21925] Add MasterPasswordSalt Column to User Table (#6950) feat: add MasterPasswordSalt column to User table - Add MasterPasswordSalt column to User table in both Dapper and EF implementations - Update User stored procedures (Create, Update, UpdateMasterPassword) to handle salt column - Add EF migrations and update UserView with dependent views - Set MaxLength constraint on MasterPasswordSalt column - Update UserRepository implementations to manage salt field - Add comprehensive test coverage for salt handling and normalization --- src/Core/Entities/User.cs | 4 +- .../Repositories/UserRepository.cs | 3 +- .../BaseEntityFrameworkRepository.cs | 2 - .../Repositories/Repository.cs | 2 - .../Repositories/UserRepository.cs | 4 +- .../User_UpdateMasterPassword.sql | 6 +- src/Sql/dbo/Stored Procedures/User_Create.sql | 9 +- src/Sql/dbo/Stored Procedures/User_Update.sql | 6 +- src/Sql/dbo/Tables/User.sql | 2 +- src/Sql/dbo/Views/UserView.sql | 3 +- .../Controllers/AccountsControllerTest.cs | 79 + .../Repositories/UserRepositoryTests.cs | 143 + ...2-28_00_AlterUserAddMasterPasswordSalt.sql | 69 + ...2026-02-28_01_AlterUserCreateAndUpdate.sql | 269 ++ ...026-02-28_02_AlterUpdateMasterPassword.sql | 33 + ...dMasterPasswordSaltToUserTable.Designer.cs | 3589 ++++++++++++++++ ...152538_AddMasterPasswordSaltToUserTable.cs | 29 + .../DatabaseContextModelSnapshot.cs | 4 + ...dMasterPasswordSaltToUserTable.Designer.cs | 3595 +++++++++++++++++ ...152522_AddMasterPasswordSaltToUserTable.cs | 28 + .../DatabaseContextModelSnapshot.cs | 4 + ...dMasterPasswordSaltToUserTable.Designer.cs | 3578 ++++++++++++++++ ...152530_AddMasterPasswordSaltToUserTable.cs | 28 + .../DatabaseContextModelSnapshot.cs | 4 + 24 files changed, 11474 insertions(+), 19 deletions(-) create mode 100644 util/Migrator/DbScripts/2026-02-28_00_AlterUserAddMasterPasswordSalt.sql create mode 100644 util/Migrator/DbScripts/2026-02-28_01_AlterUserCreateAndUpdate.sql create mode 100644 util/Migrator/DbScripts/2026-02-28_02_AlterUpdateMasterPassword.sql create mode 100644 util/MySqlMigrations/Migrations/20260303152538_AddMasterPasswordSaltToUserTable.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20260303152538_AddMasterPasswordSaltToUserTable.cs create mode 100644 util/PostgresMigrations/Migrations/20260303152522_AddMasterPasswordSaltToUserTable.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20260303152522_AddMasterPasswordSaltToUserTable.cs create mode 100644 util/SqliteMigrations/Migrations/20260303152530_AddMasterPasswordSaltToUserTable.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20260303152530_AddMasterPasswordSaltToUserTable.cs diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index 374dbfa08309..94dec8015b5d 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -111,8 +111,8 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac /// Allows clients to unlock vault after V1 to V2 key rotation without logout. /// public string? V2UpgradeToken { get; set; } - // PM-28827 Uncomment below line. - // public string? MasterPasswordSalt { get; set; } + [MaxLength(256)] + public string? MasterPasswordSalt { get; set; } public string GetMasterPasswordSalt() { diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index a248e10e81f2..eaeaf5f1805a 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -486,7 +486,8 @@ public UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData ma KdfMemory = masterPasswordUnlockData.Kdf.Memory, KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism, RevisionDate = timestamp, - AccountRevisionDate = timestamp + AccountRevisionDate = timestamp, + MasterPasswordSalt = masterPasswordUnlockData.Salt }, transaction: transaction, commandType: CommandType.StoredProcedure); diff --git a/src/Infrastructure.EntityFramework/Repositories/BaseEntityFrameworkRepository.cs b/src/Infrastructure.EntityFramework/Repositories/BaseEntityFrameworkRepository.cs index 0039e881fbbb..6cf7cbb46efc 100644 --- a/src/Infrastructure.EntityFramework/Repositories/BaseEntityFrameworkRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/BaseEntityFrameworkRepository.cs @@ -7,8 +7,6 @@ using Microsoft.Extensions.DependencyInjection; using User = Bit.Core.Entities.User; -#nullable enable - namespace Bit.Infrastructure.EntityFramework.Repositories; public abstract class BaseEntityFrameworkRepository diff --git a/src/Infrastructure.EntityFramework/Repositories/Repository.cs b/src/Infrastructure.EntityFramework/Repositories/Repository.cs index 7b5d6c7df517..e26db55d714c 100644 --- a/src/Infrastructure.EntityFramework/Repositories/Repository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/Repository.cs @@ -5,8 +5,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -#nullable enable - namespace Bit.Infrastructure.EntityFramework.Repositories; public abstract class Repository : BaseEntityFrameworkRepository, IRepository diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index a4237187843a..c364eeb8bc4c 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -10,8 +10,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -#nullable enable - namespace Bit.Infrastructure.EntityFramework.Repositories; public class UserRepository : Repository, IUserRepository @@ -563,7 +561,7 @@ public UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData ma userEntity.KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism; userEntity.RevisionDate = timestamp; userEntity.AccountRevisionDate = timestamp; - + userEntity.MasterPasswordSalt = masterPasswordUnlockData.Salt; await dbContext.SaveChangesAsync(); }; } diff --git a/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateMasterPassword.sql b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateMasterPassword.sql index 42b3cbcb8457..e601171609e7 100644 --- a/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateMasterPassword.sql +++ b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateMasterPassword.sql @@ -8,7 +8,8 @@ CREATE PROCEDURE [dbo].[User_UpdateMasterPassword] @KdfMemory INT = NULL, @KdfParallelism INT = NULL, @RevisionDate DATETIME2(7), - @AccountRevisionDate DATETIME2(7) + @AccountRevisionDate DATETIME2(7), + @MasterPasswordSalt NVARCHAR(256) = NULL AS BEGIN SET NOCOUNT ON @@ -24,7 +25,8 @@ BEGIN [KdfMemory] = @KdfMemory, [KdfParallelism] = @KdfParallelism, [RevisionDate] = @RevisionDate, - [AccountRevisionDate] = @AccountRevisionDate + [AccountRevisionDate] = @AccountRevisionDate, + [MasterPasswordSalt] = @MasterPasswordSalt WHERE [Id] = @Id END diff --git a/src/Sql/dbo/Stored Procedures/User_Create.sql b/src/Sql/dbo/Stored Procedures/User_Create.sql index a751dc202125..31735592e783 100644 --- a/src/Sql/dbo/Stored Procedures/User_Create.sql +++ b/src/Sql/dbo/Stored Procedures/User_Create.sql @@ -45,7 +45,8 @@ @SecurityState VARCHAR(MAX) = NULL, @SecurityVersion INT = NULL, @SignedPublicKey VARCHAR(MAX) = NULL, - @V2UpgradeToken VARCHAR(MAX) = NULL + @V2UpgradeToken VARCHAR(MAX) = NULL, + @MasterPasswordSalt NVARCHAR(256) = NULL AS BEGIN SET NOCOUNT ON @@ -99,7 +100,8 @@ BEGIN [SecurityVersion], [SignedPublicKey], [MaxStorageGbIncreased], - [V2UpgradeToken] + [V2UpgradeToken], + [MasterPasswordSalt] ) VALUES ( @@ -150,6 +152,7 @@ BEGIN @SecurityVersion, @SignedPublicKey, @MaxStorageGb, - @V2UpgradeToken + @V2UpgradeToken, + @MasterPasswordSalt ) END diff --git a/src/Sql/dbo/Stored Procedures/User_Update.sql b/src/Sql/dbo/Stored Procedures/User_Update.sql index 48c485ea9713..ce4b759eafee 100644 --- a/src/Sql/dbo/Stored Procedures/User_Update.sql +++ b/src/Sql/dbo/Stored Procedures/User_Update.sql @@ -45,7 +45,8 @@ @SecurityState VARCHAR(MAX) = NULL, @SecurityVersion INT = NULL, @SignedPublicKey VARCHAR(MAX) = NULL, - @V2UpgradeToken VARCHAR(MAX) = NULL + @V2UpgradeToken VARCHAR(MAX) = NULL, + @MasterPasswordSalt NVARCHAR(256) = NULL AS BEGIN SET NOCOUNT ON @@ -99,7 +100,8 @@ BEGIN [SecurityVersion] = @SecurityVersion, [SignedPublicKey] = @SignedPublicKey, [MaxStorageGbIncreased] = @MaxStorageGb, - [V2UpgradeToken] = @V2UpgradeToken + [V2UpgradeToken] = @V2UpgradeToken, + [MasterPasswordSalt] = @MasterPasswordSalt WHERE [Id] = @Id END diff --git a/src/Sql/dbo/Tables/User.sql b/src/Sql/dbo/Tables/User.sql index 95099ea52fdc..4b572640757c 100644 --- a/src/Sql/dbo/Tables/User.sql +++ b/src/Sql/dbo/Tables/User.sql @@ -47,10 +47,10 @@ [SignedPublicKey] VARCHAR (MAX) NULL, [MaxStorageGbIncreased] SMALLINT NULL, [V2UpgradeToken] VARCHAR(MAX) NULL, + [MasterPasswordSalt] NVARCHAR (256) NULL, CONSTRAINT [PK_User] PRIMARY KEY CLUSTERED ([Id] ASC) ); - GO CREATE UNIQUE NONCLUSTERED INDEX [IX_User_Email] ON [dbo].[User]([Email] ASC); diff --git a/src/Sql/dbo/Views/UserView.sql b/src/Sql/dbo/Views/UserView.sql index 6ed156a2fa00..eda159fa4489 100644 --- a/src/Sql/dbo/Views/UserView.sql +++ b/src/Sql/dbo/Views/UserView.sql @@ -47,6 +47,7 @@ SELECT [SecurityState], [SecurityVersion], [SignedPublicKey], - [V2UpgradeToken] + [V2UpgradeToken], + [MasterPasswordSalt] FROM [dbo].[User] diff --git a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs index 9860775e312a..833f724ae375 100644 --- a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs @@ -923,6 +923,85 @@ public async Task PostSetPasswordAsync_V2_InvalidKdfSettings_ReturnsBadRequest( Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + [Fact] + public async Task PostEmail_Success_UpdatesEmailAndPassword() + { + // Arrange + var newEmail = $"new-email-{Guid.NewGuid()}@bitwarden.com"; + await _loginHelper.LoginAsync(_ownerEmail); + + var user = await _userRepository.GetByEmailAsync(_ownerEmail); + Assert.NotNull(user); + + var userManager = _factory.GetService>(); + var token = await userManager.GenerateChangeEmailTokenAsync(user, newEmail); + + // Act + var response = await PostEmailAsync(newEmail, token); + + // Assert + response.EnsureSuccessStatusCode(); + + var updatedUser = await _userRepository.GetByEmailAsync(newEmail); + Assert.NotNull(updatedUser); + Assert.Equal(newEmail, updatedUser.Email); + Assert.True(updatedUser.EmailVerified); + Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key); + Assert.Equal(PasswordVerificationResult.Success, + _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword!, _newMasterPasswordHash)); + } + + [Fact] + public async Task PostEmail_WhenInvalidMasterPassword_ReturnsBadRequest() + { + // Arrange + var newEmail = $"new-email-{Guid.NewGuid()}@bitwarden.com"; + await _loginHelper.LoginAsync(_ownerEmail); + + var user = await _userRepository.GetByEmailAsync(_ownerEmail); + Assert.NotNull(user); + + var userManager = _factory.GetService>(); + var token = await userManager.GenerateChangeEmailTokenAsync(user, newEmail); + + var requestModel = new EmailRequestModel + { + MasterPasswordHash = "wrong_master_password_hash", + NewEmail = newEmail, + NewMasterPasswordHash = _newMasterPasswordHash, + Token = token, + Key = _masterKeyWrappedUserKey + }; + + // Act + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/email"); + message.Content = JsonContent.Create(requestModel); + var response = await _client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + // Verify email was not changed + var unchangedUser = await _userRepository.GetByEmailAsync(_ownerEmail); + Assert.NotNull(unchangedUser); + } + + private async Task PostEmailAsync(string newEmail, string token) + { + var requestModel = new EmailRequestModel + { + MasterPasswordHash = _masterPasswordHash, + NewEmail = newEmail, + NewMasterPasswordHash = _newMasterPasswordHash, + Token = token, + Key = _masterKeyWrappedUserKey + }; + + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/email"); + message.Content = JsonContent.Create(requestModel); + return await _client.SendAsync(message); + } + private static string CreateV2SetPasswordRequestJson( string userEmail, string orgIdentifier, diff --git a/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs index f205a10c7e5d..3bb3a70b0dc4 100644 --- a/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs @@ -1,7 +1,9 @@ using Bit.Core; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.UserFeatures.UserMasterPassword; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; using Bit.Core.Repositories; @@ -528,6 +530,147 @@ public async Task SetKeyConnectorUserKey_UpdatesUserKey(IUserRepository userRepo Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1)); } + /// + /// Happy Path + /// + [Theory, DatabaseData] + public async Task CreateAsync_ShouldCreateUser( + IUserRepository userRepository) + { + // Arrange + var email = $"TesT+{Guid.NewGuid()}@example.com"; + var passwordSalt = "some_guid_or_random_string"; + var user = new User + { + Name = "Test User", + Email = email, + ApiKey = "TEST", + SecurityStamp = "stamp", + MasterPassword = "password_hash", + MasterPasswordSalt = passwordSalt + }; + + // Act + user = await userRepository.CreateAsync(user); + + // Assert + var createdUser = await userRepository.GetByIdAsync(user.Id); + Assert.NotNull(createdUser); + Assert.Equal("Test User", createdUser.Name); + Assert.Equal(email, createdUser.Email); + Assert.Equal("TEST", createdUser.ApiKey); + Assert.Equal("stamp", createdUser.SecurityStamp); + Assert.Equal("password_hash", createdUser.MasterPassword); + Assert.Equal(passwordSalt, createdUser.MasterPasswordSalt); + } + + /// + /// Happy path + /// + [Theory, DatabaseData] + public async Task ReplaceAsync_ShouldUpdateUser( + IUserRepository userRepository) + { + // Arrange + var originalEmail = $"original+{Guid.NewGuid()}@example.com"; + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = originalEmail, + ApiKey = "TEST", + SecurityStamp = "stamp", + MasterPassword = "password_hash", + }); + + // Act + var newEmail = $"UpDAted+{Guid.NewGuid()}@example.com"; + user.Email = newEmail; + var passwordSalt = "some_guid_or_random_string"; + user.MasterPasswordSalt = passwordSalt; + await userRepository.ReplaceAsync(user); + + // Assert + var updatedUser = await userRepository.GetByIdAsync(user.Id); + Assert.NotNull(updatedUser); + Assert.Equal("Test User", updatedUser.Name); + Assert.Equal("TEST", updatedUser.ApiKey); + Assert.Equal("stamp", updatedUser.SecurityStamp); + Assert.Equal("password_hash", updatedUser.MasterPassword); + // Assert updates where made + Assert.Equal(newEmail, updatedUser.Email); + Assert.Equal(passwordSalt, updatedUser.MasterPasswordSalt); + } + + [Theory, DatabaseData] + public async Task CreateAsync_ShouldSetMasterPasswordSaltToNullWhenNoMasterPassword( + IUserRepository userRepository) + { + // Arrange + var originalEmail = $"OriGinaL+{Guid.NewGuid()}@example.com"; + var user = new User + { + Name = "Test User", + Email = originalEmail, + ApiKey = "TEST", + SecurityStamp = "stamp" + }; + + // Act + await userRepository.CreateAsync(user); + + // Assert + var updatedUser = await userRepository.GetByIdAsync(user.Id); + Assert.NotNull(updatedUser); + Assert.Null(updatedUser.MasterPasswordSalt); + } + + /// + /// In this test we are testing that the MasterPasswordUnlockData set's the password data correctly. + /// including setting the masterPasswordSalt. + /// and for reference. + /// + [Theory, DatabaseData] + public async Task UpdateMasterPassword_MasterPasswordSaltIsUpdated( + IUserRepository userRepository, Database database) + { + // Arrange + var originalEmail = $"OriGinaL+{Guid.NewGuid()}@example.com"; + var masterPasswordUnlockData = new MasterPasswordUnlockData + { + Kdf = new KdfSettings + { + KdfType = KdfType.Argon2id, + Iterations = AuthConstants.ARGON2_ITERATIONS.Default, + Memory = AuthConstants.ARGON2_MEMORY.Default, + Parallelism = AuthConstants.ARGON2_PARALLELISM.Default + }, + MasterKeyWrappedUserKey = "wrapped-user-key", + // The salt is set to the email in the command handlers, so we can set + // it to the email here to verify it gets set correctly on the user. + Salt = originalEmail.ToLowerInvariant().Trim() + }; + + // Create user with no master password so that the MasterPasswordSalt will be null + // initially and we can verify it gets set on update. + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = originalEmail, + ApiKey = "TEST", + SecurityStamp = "stamp" + }); + + // Act + var result = userRepository.SetMasterPassword(user.Id, masterPasswordUnlockData, "newMasterPasswordHash", "hint"); + Assert.NotNull(result); + await RunUpdateUserDataAsync(result, database); + + var updatedUser = await userRepository.GetByIdAsync(user.Id); + Assert.NotNull(updatedUser); + Assert.NotNull(updatedUser.MasterPasswordSalt); + Assert.Equal(originalEmail.ToLowerInvariant().Trim(), updatedUser.MasterPasswordSalt); + } + [Theory, DatabaseData] public async Task UpdateUserKeyAndEncryptedDataV2Async_UpdatesAllUserFields(IUserRepository userRepository) { diff --git a/util/Migrator/DbScripts/2026-02-28_00_AlterUserAddMasterPasswordSalt.sql b/util/Migrator/DbScripts/2026-02-28_00_AlterUserAddMasterPasswordSalt.sql new file mode 100644 index 000000000000..90312c977107 --- /dev/null +++ b/util/Migrator/DbScripts/2026-02-28_00_AlterUserAddMasterPasswordSalt.sql @@ -0,0 +1,69 @@ +IF COL_LENGTH('[dbo].[User]', 'MasterPasswordSalt') IS NULL +BEGIN + ALTER TABLE [dbo].[User] ADD [MasterPasswordSalt] NVARCHAR(256) NULL; +END +GO + +-- Update UserView to include MasterPasswordSalt +CREATE OR ALTER VIEW [dbo].[UserView] +AS + SELECT + [Id], + [Name], + [Email], + [EmailVerified], + [MasterPassword], + [MasterPasswordHint], + [Culture], + [SecurityStamp], + [TwoFactorProviders], + [TwoFactorRecoveryCode], + [EquivalentDomains], + [ExcludedGlobalEquivalentDomains], + [AccountRevisionDate], + [Key], + [PublicKey], + [PrivateKey], + [Premium], + [PremiumExpirationDate], + [RenewalReminderDate], + [Storage], + COALESCE([MaxStorageGbIncreased], [MaxStorageGb]) AS [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [ReferenceData], + [LicenseKey], + [ApiKey], + [Kdf], + [KdfIterations], + [KdfMemory], + [KdfParallelism], + [CreationDate], + [RevisionDate], + [ForcePasswordReset], + [UsesKeyConnector], + [FailedLoginCount], + [LastFailedLoginDate], + [AvatarColor], + [LastPasswordChangeDate], + [LastKdfChangeDate], + [LastKeyRotationDate], + [LastEmailChangeDate], + [VerifyDevices], + [SecurityState], + [SecurityVersion], + [SignedPublicKey], + [V2UpgradeToken], + [MasterPasswordSalt] + FROM + [dbo].[User] +GO + +-- Refresh views that depend on UserView to ensure they include the new MasterPasswordSalt column +EXEC sp_refreshview N'[dbo].[EmergencyAccessDetailsView]'; +EXEC sp_refreshview N'[dbo].[OrganizationUserUserDetailsView]'; +EXEC sp_refreshview N'[dbo].[ProviderUserUserDetailsView]'; +EXEC sp_refreshview N'[dbo].[UserEmailDomainView]'; +EXEC sp_refreshview N'[dbo].[UserPremiumAccessView]'; +GO diff --git a/util/Migrator/DbScripts/2026-02-28_01_AlterUserCreateAndUpdate.sql b/util/Migrator/DbScripts/2026-02-28_01_AlterUserCreateAndUpdate.sql new file mode 100644 index 000000000000..038fa7d921ac --- /dev/null +++ b/util/Migrator/DbScripts/2026-02-28_01_AlterUserCreateAndUpdate.sql @@ -0,0 +1,269 @@ +CREATE OR ALTER PROCEDURE [dbo].[User_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @Name NVARCHAR(50), + @Email NVARCHAR(256), + @EmailVerified BIT, + @MasterPassword NVARCHAR(300), + @MasterPasswordHint NVARCHAR(50), + @Culture NVARCHAR(10), + @SecurityStamp NVARCHAR(50), + @TwoFactorProviders NVARCHAR(MAX), + @TwoFactorRecoveryCode NVARCHAR(32), + @EquivalentDomains NVARCHAR(MAX), + @ExcludedGlobalEquivalentDomains NVARCHAR(MAX), + @AccountRevisionDate DATETIME2(7), + @Key NVARCHAR(MAX), + @PublicKey NVARCHAR(MAX), + @PrivateKey NVARCHAR(MAX), + @Premium BIT, + @PremiumExpirationDate DATETIME2(7), + @RenewalReminderDate DATETIME2(7), + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @LicenseKey VARCHAR(100), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT = NULL, + @KdfParallelism INT = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @ApiKey VARCHAR(30), + @ForcePasswordReset BIT = 0, + @UsesKeyConnector BIT = 0, + @FailedLoginCount INT = 0, + @LastFailedLoginDate DATETIME2(7), + @AvatarColor VARCHAR(7) = NULL, + @LastPasswordChangeDate DATETIME2(7) = NULL, + @LastKdfChangeDate DATETIME2(7) = NULL, + @LastKeyRotationDate DATETIME2(7) = NULL, + @LastEmailChangeDate DATETIME2(7) = NULL, + @VerifyDevices BIT = 1, + @SecurityState VARCHAR(MAX) = NULL, + @SecurityVersion INT = NULL, + @SignedPublicKey VARCHAR(MAX) = NULL, + @V2UpgradeToken VARCHAR(MAX) = NULL, + @MasterPasswordSalt NVARCHAR(256) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[User] + ( + [Id], + [Name], + [Email], + [EmailVerified], + [MasterPassword], + [MasterPasswordHint], + [Culture], + [SecurityStamp], + [TwoFactorProviders], + [TwoFactorRecoveryCode], + [EquivalentDomains], + [ExcludedGlobalEquivalentDomains], + [AccountRevisionDate], + [Key], + [PublicKey], + [PrivateKey], + [Premium], + [PremiumExpirationDate], + [RenewalReminderDate], + [Storage], + [MaxStorageGb], + [Gateway], + [GatewayCustomerId], + [GatewaySubscriptionId], + [ReferenceData], + [LicenseKey], + [Kdf], + [KdfIterations], + [CreationDate], + [RevisionDate], + [ApiKey], + [ForcePasswordReset], + [UsesKeyConnector], + [FailedLoginCount], + [LastFailedLoginDate], + [AvatarColor], + [KdfMemory], + [KdfParallelism], + [LastPasswordChangeDate], + [LastKdfChangeDate], + [LastKeyRotationDate], + [LastEmailChangeDate], + [VerifyDevices], + [SecurityState], + [SecurityVersion], + [SignedPublicKey], + [MaxStorageGbIncreased], + [V2UpgradeToken], + [MasterPasswordSalt] + ) + VALUES + ( + @Id, + @Name, + @Email, + @EmailVerified, + @MasterPassword, + @MasterPasswordHint, + @Culture, + @SecurityStamp, + @TwoFactorProviders, + @TwoFactorRecoveryCode, + @EquivalentDomains, + @ExcludedGlobalEquivalentDomains, + @AccountRevisionDate, + @Key, + @PublicKey, + @PrivateKey, + @Premium, + @PremiumExpirationDate, + @RenewalReminderDate, + @Storage, + @MaxStorageGb, + @Gateway, + @GatewayCustomerId, + @GatewaySubscriptionId, + @ReferenceData, + @LicenseKey, + @Kdf, + @KdfIterations, + @CreationDate, + @RevisionDate, + @ApiKey, + @ForcePasswordReset, + @UsesKeyConnector, + @FailedLoginCount, + @LastFailedLoginDate, + @AvatarColor, + @KdfMemory, + @KdfParallelism, + @LastPasswordChangeDate, + @LastKdfChangeDate, + @LastKeyRotationDate, + @LastEmailChangeDate, + @VerifyDevices, + @SecurityState, + @SecurityVersion, + @SignedPublicKey, + @MaxStorageGb, + @V2UpgradeToken, + @MasterPasswordSalt + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[User_Update] + @Id UNIQUEIDENTIFIER, + @Name NVARCHAR(50), + @Email NVARCHAR(256), + @EmailVerified BIT, + @MasterPassword NVARCHAR(300), + @MasterPasswordHint NVARCHAR(50), + @Culture NVARCHAR(10), + @SecurityStamp NVARCHAR(50), + @TwoFactorProviders NVARCHAR(MAX), + @TwoFactorRecoveryCode NVARCHAR(32), + @EquivalentDomains NVARCHAR(MAX), + @ExcludedGlobalEquivalentDomains NVARCHAR(MAX), + @AccountRevisionDate DATETIME2(7), + @Key NVARCHAR(MAX), + @PublicKey NVARCHAR(MAX), + @PrivateKey NVARCHAR(MAX), + @Premium BIT, + @PremiumExpirationDate DATETIME2(7), + @RenewalReminderDate DATETIME2(7), + @Storage BIGINT, + @MaxStorageGb SMALLINT, + @Gateway TINYINT, + @GatewayCustomerId VARCHAR(50), + @GatewaySubscriptionId VARCHAR(50), + @ReferenceData VARCHAR(MAX), + @LicenseKey VARCHAR(100), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT = NULL, + @KdfParallelism INT = NULL, + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @ApiKey VARCHAR(30), + @ForcePasswordReset BIT = 0, + @UsesKeyConnector BIT = 0, + @FailedLoginCount INT, + @LastFailedLoginDate DATETIME2(7), + @AvatarColor VARCHAR(7), + @LastPasswordChangeDate DATETIME2(7) = NULL, + @LastKdfChangeDate DATETIME2(7) = NULL, + @LastKeyRotationDate DATETIME2(7) = NULL, + @LastEmailChangeDate DATETIME2(7) = NULL, + @VerifyDevices BIT = 1, + @SecurityState VARCHAR(MAX) = NULL, + @SecurityVersion INT = NULL, + @SignedPublicKey VARCHAR(MAX) = NULL, + @V2UpgradeToken VARCHAR(MAX) = NULL, + @MasterPasswordSalt NVARCHAR(256) = NULL +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [Name] = @Name, + [Email] = @Email, + [EmailVerified] = @EmailVerified, + [MasterPassword] = @MasterPassword, + [MasterPasswordHint] = @MasterPasswordHint, + [Culture] = @Culture, + [SecurityStamp] = @SecurityStamp, + [TwoFactorProviders] = @TwoFactorProviders, + [TwoFactorRecoveryCode] = @TwoFactorRecoveryCode, + [EquivalentDomains] = @EquivalentDomains, + [ExcludedGlobalEquivalentDomains] = @ExcludedGlobalEquivalentDomains, + [AccountRevisionDate] = @AccountRevisionDate, + [Key] = @Key, + [PublicKey] = @PublicKey, + [PrivateKey] = @PrivateKey, + [Premium] = @Premium, + [PremiumExpirationDate] = @PremiumExpirationDate, + [RenewalReminderDate] = @RenewalReminderDate, + [Storage] = @Storage, + [MaxStorageGb] = @MaxStorageGb, + [Gateway] = @Gateway, + [GatewayCustomerId] = @GatewayCustomerId, + [GatewaySubscriptionId] = @GatewaySubscriptionId, + [ReferenceData] = @ReferenceData, + [LicenseKey] = @LicenseKey, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [KdfMemory] = @KdfMemory, + [KdfParallelism] = @KdfParallelism, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [ApiKey] = @ApiKey, + [ForcePasswordReset] = @ForcePasswordReset, + [UsesKeyConnector] = @UsesKeyConnector, + [FailedLoginCount] = @FailedLoginCount, + [LastFailedLoginDate] = @LastFailedLoginDate, + [AvatarColor] = @AvatarColor, + [LastPasswordChangeDate] = @LastPasswordChangeDate, + [LastKdfChangeDate] = @LastKdfChangeDate, + [LastKeyRotationDate] = @LastKeyRotationDate, + [LastEmailChangeDate] = @LastEmailChangeDate, + [VerifyDevices] = @VerifyDevices, + [SecurityState] = @SecurityState, + [SecurityVersion] = @SecurityVersion, + [SignedPublicKey] = @SignedPublicKey, + [MaxStorageGbIncreased] = @MaxStorageGb, + [V2UpgradeToken] = @V2UpgradeToken, + [MasterPasswordSalt] = @MasterPasswordSalt + WHERE + [Id] = @Id +END +GO + diff --git a/util/Migrator/DbScripts/2026-02-28_02_AlterUpdateMasterPassword.sql b/util/Migrator/DbScripts/2026-02-28_02_AlterUpdateMasterPassword.sql new file mode 100644 index 000000000000..5bed4b945268 --- /dev/null +++ b/util/Migrator/DbScripts/2026-02-28_02_AlterUpdateMasterPassword.sql @@ -0,0 +1,33 @@ +CREATE OR ALTER PROCEDURE [dbo].[User_UpdateMasterPassword] + @Id UNIQUEIDENTIFIER, + @MasterPassword NVARCHAR(300), + @MasterPasswordHint NVARCHAR(50) = NULL, + @Key VARCHAR(MAX), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT = NULL, + @KdfParallelism INT = NULL, + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7), + @MasterPasswordSalt NVARCHAR(256) = NULL -- NULL for backwards compat. +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [MasterPassword] = @MasterPassword, + [MasterPasswordHint] = @MasterPasswordHint, + [Key] = @Key, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [KdfMemory] = @KdfMemory, + [KdfParallelism] = @KdfParallelism, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate, + [MasterPasswordSalt] = @MasterPasswordSalt + WHERE + [Id] = @Id +END +GO diff --git a/util/MySqlMigrations/Migrations/20260303152538_AddMasterPasswordSaltToUserTable.Designer.cs b/util/MySqlMigrations/Migrations/20260303152538_AddMasterPasswordSaltToUserTable.Designer.cs new file mode 100644 index 000000000000..10a9dd021251 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20260303152538_AddMasterPasswordSaltToUserTable.Designer.cs @@ -0,0 +1,3589 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260303152538_AddMasterPasswordSaltToUserTable")] + partial class AddMasterPasswordSaltToUserTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CollectionName") + .HasColumnType("longtext"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("GroupName") + .HasColumnType("longtext"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("UserGuid") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasColumnType("longtext"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("tinyint(1)"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("LimitItemDeletion") + .HasColumnType("tinyint(1)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("int"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("int"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("datetime(6)"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("SelfHost") + .HasColumnType("tinyint(1)"); + + b.Property("SmSeats") + .HasColumnType("int"); + + b.Property("SmServiceAccounts") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("tinyint(1)"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("Use2fa") + .HasColumnType("tinyint(1)"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("tinyint(1)"); + + b.Property("UseApi") + .HasColumnType("tinyint(1)"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("tinyint(1)"); + + b.Property("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UseMyItems") + .HasColumnType("tinyint(1)"); + + b.Property("UseOrganizationDomains") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .HasColumnType("tinyint(1)"); + + b.Property("UsePhishingBlocker") + .HasColumnType("tinyint(1)"); + + b.Property("UsePolicies") + .HasColumnType("tinyint(1)"); + + b.Property("UseResetPassword") + .HasColumnType("tinyint(1)"); + + b.Property("UseRiskInsights") + .HasColumnType("tinyint(1)"); + + b.Property("UseScim") + .HasColumnType("tinyint(1)"); + + b.Property("UseSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("UseSso") + .HasColumnType("tinyint(1)"); + + b.Property("UseTotp") + .HasColumnType("tinyint(1)"); + + b.Property("UsersGetPremium") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("BillingEmail") + .HasColumnType("longtext"); + + b.Property("BillingPhone") + .HasColumnType("longtext"); + + b.Property("BusinessAddress1") + .HasColumnType("longtext"); + + b.Property("BusinessAddress2") + .HasColumnType("longtext"); + + b.Property("BusinessAddress3") + .HasColumnType("longtext"); + + b.Property("BusinessCountry") + .HasColumnType("longtext"); + + b.Property("BusinessName") + .HasColumnType("longtext"); + + b.Property("BusinessTaxNumber") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DiscountId") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Settings") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("varchar(25)"); + + b.Property("Approved") + .HasColumnType("tinyint(1)"); + + b.Property("AuthenticationDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MasterPasswordHash") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.SubscriptionDiscount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AmountOff") + .HasColumnType("bigint"); + + b.Property("AudienceType") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Duration") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("DurationInMonths") + .HasColumnType("int"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PercentOff") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("StripeCouponId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("StripeProductIds") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("StripeCouponId") + .IsUnique(); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_SubscriptionDiscount_DateRange") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SubscriptionDiscount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Filters") + .HasColumnType("longtext"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("ApplicationCount") + .HasColumnType("int"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalApplicationCount") + .HasColumnType("int"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalMemberCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordCount") + .HasColumnType("int"); + + b.Property("MemberAtRiskCount") + .HasColumnType("int"); + + b.Property("MemberCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("PasswordCount") + .HasColumnType("int"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("longtext"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProjectId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("AuthType") + .HasColumnType("tinyint unsigned"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SecurityState") + .HasColumnType("longtext"); + + b.Property("SecurityVersion") + .HasColumnType("int"); + + b.Property("SignedPublicKey") + .HasColumnType("longtext"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("V2UpgradeToken") + .HasColumnType("longtext"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SignatureAlgorithm") + .HasColumnType("tinyint unsigned"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("EditorServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VersionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Archives") + .HasColumnType("longtext"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20260303152538_AddMasterPasswordSaltToUserTable.cs b/util/MySqlMigrations/Migrations/20260303152538_AddMasterPasswordSaltToUserTable.cs new file mode 100644 index 000000000000..27917d80d46b --- /dev/null +++ b/util/MySqlMigrations/Migrations/20260303152538_AddMasterPasswordSaltToUserTable.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class AddMasterPasswordSaltToUserTable : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MasterPasswordSalt", + table: "User", + type: "varchar(256)", + maxLength: 256, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MasterPasswordSalt", + table: "User"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index c166b3210277..71ac17e4d09d 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2003,6 +2003,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(50) .HasColumnType("varchar(50)"); + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + b.Property("MaxStorageGb") .HasColumnType("smallint"); diff --git a/util/PostgresMigrations/Migrations/20260303152522_AddMasterPasswordSaltToUserTable.Designer.cs b/util/PostgresMigrations/Migrations/20260303152522_AddMasterPasswordSaltToUserTable.Designer.cs new file mode 100644 index 000000000000..4c238ff3672d --- /dev/null +++ b/util/PostgresMigrations/Migrations/20260303152522_AddMasterPasswordSaltToUserTable.Designer.cs @@ -0,0 +1,3595 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260303152522_AddMasterPasswordSaltToUserTable")] + partial class AddMasterPasswordSaltToUserTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CollectionName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("GroupName") + .HasColumnType("text"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("UserGuid") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("boolean"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UseMyItems") + .HasColumnType("boolean"); + + b.Property("UseOrganizationDomains") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePhishingBlocker") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.SubscriptionDiscount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountOff") + .HasColumnType("bigint"); + + b.Property("AudienceType") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Duration") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("DurationInMonths") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PercentOff") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeCouponId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StripeProductIds") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("StripeCouponId") + .IsUnique(); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_SubscriptionDiscount_DateRange") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SubscriptionDiscount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("Filters") + .HasColumnType("text"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("ApplicationCount") + .HasColumnType("integer"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalApplicationCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordCount") + .HasColumnType("integer"); + + b.Property("MemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("MemberCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("PasswordCount") + .HasColumnType("integer"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("AuthType") + .HasColumnType("smallint"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SecurityState") + .HasColumnType("text"); + + b.Property("SecurityVersion") + .HasColumnType("integer"); + + b.Property("SignedPublicKey") + .HasColumnType("text"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("V2UpgradeToken") + .HasColumnType("text"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SignatureAlgorithm") + .HasColumnType("smallint"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("EditorServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Archives") + .HasColumnType("text"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20260303152522_AddMasterPasswordSaltToUserTable.cs b/util/PostgresMigrations/Migrations/20260303152522_AddMasterPasswordSaltToUserTable.cs new file mode 100644 index 000000000000..dbd90d773f47 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20260303152522_AddMasterPasswordSaltToUserTable.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class AddMasterPasswordSaltToUserTable : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MasterPasswordSalt", + table: "User", + type: "character varying(256)", + maxLength: 256, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MasterPasswordSalt", + table: "User"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index d226fd60c75e..b3f3f95d4ebf 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2009,6 +2009,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(50) .HasColumnType("character varying(50)"); + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + b.Property("MaxStorageGb") .HasColumnType("smallint"); diff --git a/util/SqliteMigrations/Migrations/20260303152530_AddMasterPasswordSaltToUserTable.Designer.cs b/util/SqliteMigrations/Migrations/20260303152530_AddMasterPasswordSaltToUserTable.Designer.cs new file mode 100644 index 000000000000..5fce87b03244 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20260303152530_AddMasterPasswordSaltToUserTable.Designer.cs @@ -0,0 +1,3578 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260303152530_AddMasterPasswordSaltToUserTable")] + partial class AddMasterPasswordSaltToUserTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("UserGuid") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("SyncSeats") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseDisableSmAdsForUsers") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UseMyItems") + .HasColumnType("INTEGER"); + + b.Property("UseOrganizationDomains") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePhishingBlocker") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp", "UsersGetPremium" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Policy", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderOrganization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId"); + + b.ToTable("ProviderUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.SubscriptionDiscount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AmountOff") + .HasColumnType("INTEGER"); + + b.Property("AudienceType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Currency") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Duration") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("DurationInMonths") + .HasColumnType("INTEGER"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PercentOff") + .HasPrecision(5, 2) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("StripeCouponId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StripeProductIds") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StripeCouponId") + .IsUnique(); + + b.HasIndex("StartDate", "EndDate") + .HasDatabaseName("IX_SubscriptionDiscount_DateRange") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SubscriptionDiscount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Filters") + .HasColumnType("TEXT"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordCount") + .HasColumnType("INTEGER"); + + b.Property("MemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("PasswordCount") + .HasColumnType("INTEGER"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlayId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("PlayId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PlayItem", null, t => + { + t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("AuthType") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("Emails") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SecurityState") + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("SignedPublicKey") + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("V2UpgradeToken") + .HasColumnType("TEXT"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("GatewayCustomerId"); + + b.HasIndex("GatewaySubscriptionId"); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SignatureAlgorithm") + .HasColumnType("INTEGER"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("EditorServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VersionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Archives") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20260303152530_AddMasterPasswordSaltToUserTable.cs b/util/SqliteMigrations/Migrations/20260303152530_AddMasterPasswordSaltToUserTable.cs new file mode 100644 index 000000000000..d7b5a0b9323e --- /dev/null +++ b/util/SqliteMigrations/Migrations/20260303152530_AddMasterPasswordSaltToUserTable.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class AddMasterPasswordSaltToUserTable : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MasterPasswordSalt", + table: "User", + type: "TEXT", + maxLength: 256, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MasterPasswordSalt", + table: "User"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 774907a4f599..15ae5bd3e208 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1992,6 +1992,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(50) .HasColumnType("TEXT"); + b.Property("MasterPasswordSalt") + .HasMaxLength(256) + .HasColumnType("TEXT"); + b.Property("MaxStorageGb") .HasColumnType("INTEGER"); From c94cb46c08ae5bf53d3002c3fb83ad3190389917 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Thu, 5 Mar 2026 11:30:14 -0600 Subject: [PATCH 17/85] PM-31923 fixing all the endpoints --- dev/.claude/settings.local.json | 8 + .../OrganizationReportsController.cs | 494 ++++++------ ...=> OrganizationReportFileResponseModel.cs} | 6 +- .../OrganizationReportResponseModel.cs | 8 +- src/Core/Constants.cs | 2 +- src/Core/Dirt/Entities/OrganizationReport.cs | 12 +- ...ganizationReportDataFileStorageResponse.cs | 6 - ....cs => CreateOrganizationReportCommand.cs} | 11 +- .../GetOrganizationReportDataV2Query.cs | 56 -- ...cs => ICreateOrganizationReportCommand.cs} | 2 +- .../IGetOrganizationReportDataV2Query.cs | 8 - .../IUpdateOrganizationReportDataV2Command.cs | 8 - .../IUpdateOrganizationReportV2Command.cs | 9 + .../ReportingServiceCollectionExtensions.cs | 7 +- .../Requests/AddOrganizationReportRequest.cs | 5 + .../UpdateOrganizationReportV2Request.cs | 13 + .../UpdateOrganizationReportDataV2Command.cs | 50 -- .../UpdateOrganizationReportV2Command.cs | 134 ++++ .../ValidateOrganizationReportFileCommand.cs | 4 +- .../AzureOrganizationReportStorageService.cs | 2 +- .../IOrganizationReportStorageService.cs | 2 +- .../LocalOrganizationReportStorageService.cs | 2 +- .../NoopOrganizationReportStorageService.cs | 2 +- .../OrganizationReportResponseModelTests.cs | 4 +- .../OrganizationReportsControllerTests.cs | 715 ++++++++++++++---- ...> CreateOrganizationReportCommandTests.cs} | 16 +- .../GetOrganizationReportDataV2QueryTests.cs | 149 ---- ...ateOrganizationReportDataV2CommandTests.cs | 95 --- .../UpdateOrganizationReportV2CommandTests.cs | 309 ++++++++ ...idateOrganizationReportFileCommandTests.cs | 4 +- ...reOrganizationReportStorageServiceTests.cs | 6 +- ...alOrganizationReportStorageServiceTests.cs | 4 +- 32 files changed, 1368 insertions(+), 785 deletions(-) create mode 100644 dev/.claude/settings.local.json rename src/Api/Dirt/Models/Response/{OrganizationReportV2ResponseModel.cs => OrganizationReportFileResponseModel.cs} (56%) delete mode 100644 src/Core/Dirt/Models/Data/OrganizationReportDataFileStorageResponse.cs rename src/Core/Dirt/Reports/ReportFeatures/{CreateOrganizationReportV2Command.cs => CreateOrganizationReportCommand.cs} (91%) delete mode 100644 src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs rename src/Core/Dirt/Reports/ReportFeatures/Interfaces/{ICreateOrganizationReportV2Command.cs => ICreateOrganizationReportCommand.cs} (81%) delete mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataV2Query.cs delete mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataV2Command.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportV2Command.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs delete mode 100644 src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs rename test/Core.Test/Dirt/ReportFeatures/{CreateOrganizationReportV2CommandTests.cs => CreateOrganizationReportCommandTests.cs} (90%) delete mode 100644 test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs delete mode 100644 test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs diff --git a/dev/.claude/settings.local.json b/dev/.claude/settings.local.json new file mode 100644 index 000000000000..ae255b535c61 --- /dev/null +++ b/dev/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet test:*)", + "Bash(dotnet build:*)" + ] + } +} diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index 43611547ab3d..067cad0dcc3f 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -3,7 +3,6 @@ using Bit.Api.Utilities; using Bit.Core; using Bit.Core.Context; -using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Reports.Services; @@ -34,10 +33,9 @@ public class OrganizationReportsController : Controller private readonly IFeatureService _featureService; private readonly IApplicationCacheService _applicationCacheService; private readonly IOrganizationReportStorageService _storageService; - private readonly ICreateOrganizationReportV2Command _createV2Command; - private readonly IUpdateOrganizationReportDataV2Command _updateDataV2Command; - private readonly IGetOrganizationReportDataV2Query _getDataV2Query; + private readonly ICreateOrganizationReportCommand _createReportCommand; private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IUpdateOrganizationReportV2Command _updateReportV2Command; private readonly IValidateOrganizationReportFileCommand _validateCommand; private readonly ILogger _logger; @@ -56,10 +54,9 @@ public OrganizationReportsController( IFeatureService featureService, IApplicationCacheService applicationCacheService, IOrganizationReportStorageService storageService, - ICreateOrganizationReportV2Command createV2Command, - IUpdateOrganizationReportDataV2Command updateDataV2Command, - IGetOrganizationReportDataV2Query getDataV2Query, + ICreateOrganizationReportCommand createReportCommand, IOrganizationReportRepository organizationReportRepo, + IUpdateOrganizationReportV2Command updateReportV2Command, IValidateOrganizationReportFileCommand validateCommand, ILogger logger) { @@ -77,43 +74,36 @@ public OrganizationReportsController( _featureService = featureService; _applicationCacheService = applicationCacheService; _storageService = storageService; - _createV2Command = createV2Command; - _updateDataV2Command = updateDataV2Command; - _getDataV2Query = getDataV2Query; + _createReportCommand = createReportCommand; _organizationReportRepo = organizationReportRepo; + _updateReportV2Command = updateReportV2Command; _validateCommand = validateCommand; _logger = logger; } + [HttpGet("{organizationId}/latest")] public async Task GetLatestOrganizationReportAsync(Guid organizationId) { - if (!await _currentContext.AccessReports(organizationId)) + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { - throw new NotFoundException(); - } - - var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); - var response = latestReport == null ? null : new OrganizationReportResponseModel(latestReport); - - return Ok(response); - } + await AuthorizeAsync(organizationId); - [HttpGet("{organizationId}/{reportId}")] - public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) - { - if (_featureService.IsEnabled(FeatureFlagKeys.WholeReportDataFileStorage)) - { - await AuthorizeV2Async(organizationId); + var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); + if (latestReport == null) + { + return Ok(null); + } - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + var response = new OrganizationReportResponseModel(latestReport); - if (report.OrganizationId != organizationId) + var fileData = latestReport.GetReportFile(); + if (fileData is { Validated: true }) { - throw new BadRequestException("Invalid report ID"); + response.ReportFileDownloadUrl = await _storageService.GetReportDataDownloadUrlAsync(latestReport, fileData); } - return Ok(new OrganizationReportResponseModel(report)); + return Ok(response); } if (!await _currentContext.AccessReports(organizationId)) @@ -121,28 +111,24 @@ public async Task GetOrganizationReportAsync(Guid organizationId, throw new NotFoundException(); } - var v1Report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - - if (v1Report == null) - { - throw new NotFoundException("Report not found for the specified organization."); - } - - if (v1Report.OrganizationId != organizationId) - { - throw new BadRequestException("Invalid report ID"); - } + var v1LatestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); + var v1Response = v1LatestReport == null ? null : new OrganizationReportResponseModel(v1LatestReport); - return Ok(v1Report); + return Ok(v1Response); } + /** + * Keeping post v2 launch of Access Intelligence + **/ + + // CREATE Whole Report [HttpPost("{organizationId}")] [RequestSizeLimit(Constants.FileSize501mb)] public async Task CreateOrganizationReportAsync( Guid organizationId, [FromBody] AddOrganizationReportRequest request) { - if (_featureService.IsEnabled(FeatureFlagKeys.WholeReportDataFileStorage)) + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { if (organizationId == Guid.Empty) { @@ -154,14 +140,24 @@ public async Task CreateOrganizationReportAsync( throw new BadRequestException("Organization ID in the request body must match the route parameter"); } - await AuthorizeV2Async(organizationId); + if (!request.FileSize.HasValue) + { + throw new BadRequestException("File size is required."); + } - var report = await _createV2Command.CreateAsync(request); - var fileData = report.GetReportFileData()!; + if (request.FileSize.Value > Constants.FileSize501mb) + { + throw new BadRequestException("Max file size is 500 MB."); + } + + await AuthorizeAsync(organizationId); + + var report = await _createReportCommand.CreateAsync(request); + var fileData = report.GetReportFile()!; - return Ok(new OrganizationReportV2ResponseModel + return Ok(new OrganizationReportFileResponseModel { - ReportDataUploadUrl = await _storageService.GetReportDataUploadUrlAsync(report, fileData), + ReportFileUploadUrl = await _storageService.GetReportFileUploadUrlAsync(report, fileData), ReportResponse = new OrganizationReportResponseModel(report), FileUploadType = _storageService.FileUploadType }); @@ -182,150 +178,81 @@ public async Task CreateOrganizationReportAsync( return Ok(response); } - [HttpPatch("{organizationId}/{reportId}")] - [RequestSizeLimit(Constants.FileSize501mb)] - public async Task UpdateOrganizationReportAsync(Guid organizationId, [FromBody] UpdateOrganizationReportRequest request) - { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } - - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - - var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(request); - var response = new OrganizationReportResponseModel(updatedReport); - return Ok(response); - } - - /// - /// Gets summary data for organization reports within a specified date range. - /// The response is optimized for widget display by returning up to 6 entries that are - /// evenly spaced across the date range, including the most recent entry. - /// This allows the widget to show trends over time while ensuring the latest data point is always included. - /// - /// - /// - /// - /// - /// - /// - [HttpGet("{organizationId}/data/summary")] - [ProducesResponseType>(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetOrganizationReportSummaryDataByDateRangeAsync( - Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) + // READ Whole Report BY IDs + [HttpGet("{organizationId}/{reportId}")] + public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) { - if (!await _currentContext.AccessReports(organizationId)) + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { - throw new NotFoundException(); - } - - if (organizationId == Guid.Empty) - { - throw new BadRequestException("Organization ID is required."); - } + await AuthorizeAsync(organizationId); - var summaryDataList = await _getOrganizationReportSummaryDataByDateRangeQuery - .GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - return Ok(summaryDataList); - } + if (report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } - [HttpGet("{organizationId}/data/summary/{reportId}")] - public async Task GetOrganizationReportSummaryAsync(Guid organizationId, Guid reportId) - { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } + var response = new OrganizationReportResponseModel(report); - var summaryData = - await _getOrganizationReportSummaryDataQuery.GetOrganizationReportSummaryDataAsync(organizationId, reportId); + var fileData = report.GetReportFile(); + if (fileData is { Validated: true }) + { + response.ReportFileDownloadUrl = await _storageService.GetReportDataDownloadUrlAsync(report, fileData); + } - if (summaryData == null) - { - throw new NotFoundException("Report not found for the specified organization."); + return Ok(response); } - return Ok(summaryData); - } - - [HttpPatch("{organizationId}/data/summary/{reportId}")] - public async Task UpdateOrganizationReportSummaryAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportSummaryRequest request) - { if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); } - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - - if (request.ReportId != reportId) - { - throw new BadRequestException("Report ID in the request body must match the route parameter"); - } - var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request); - var response = new OrganizationReportResponseModel(updatedReport); - - return Ok(response); - } + var v1Report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - [HttpGet("{organizationId}/data/report/{reportId}")] - public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) - { - if (!await _currentContext.AccessReports(organizationId)) + if (v1Report == null) { - throw new NotFoundException(); + throw new NotFoundException("Report not found for the specified organization."); } - var reportData = await _getOrganizationReportDataQuery.GetOrganizationReportDataAsync(organizationId, reportId); - - if (reportData == null) + if (v1Report.OrganizationId != organizationId) { - throw new NotFoundException("Organization report data not found."); + throw new BadRequestException("Invalid report ID"); } - return Ok(reportData); + return Ok(v1Report); } - [HttpPatch("{organizationId}/data/report/{reportId}")] - public async Task UpdateOrganizationReportDataAsync( + // UPDATE Whole Report + [HttpPatch("{organizationId}/{reportId}")] + [RequestSizeLimit(Constants.FileSize501mb)] + public async Task UpdateOrganizationReportAsync( Guid organizationId, Guid reportId, - [FromBody] UpdateOrganizationReportDataRequest request, - [FromQuery] string? reportFileId) + [FromBody] UpdateOrganizationReportV2Request request) { - if (_featureService.IsEnabled(FeatureFlagKeys.WholeReportDataFileStorage)) + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { - if (request.OrganizationId != organizationId || request.ReportId != reportId) - { - throw new BadRequestException("Organization ID and Report ID must match route parameters"); - } - - if (string.IsNullOrEmpty(reportFileId)) - { - throw new BadRequestException("ReportFileId query parameter is required"); - } + await AuthorizeAsync(organizationId); - await AuthorizeV2Async(organizationId); + request.OrganizationId = organizationId; + request.ReportId = reportId; - var uploadUrl = await _updateDataV2Command.GetUploadUrlAsync(request, reportFileId); - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + var report = await _updateReportV2Command.UpdateAsync(request); - return Ok(new OrganizationReportV2ResponseModel + if (request.RequiresNewFileUpload) { - ReportDataUploadUrl = uploadUrl, - ReportResponse = new OrganizationReportResponseModel(report), - FileUploadType = _storageService.FileUploadType - }); + var fileData = report.GetReportFile()!; + return Ok(new OrganizationReportFileResponseModel + { + ReportFileUploadUrl = await _storageService.GetReportFileUploadUrlAsync(report, fileData), + ReportResponse = new OrganizationReportResponseModel(report), + FileUploadType = _storageService.FileUploadType + }); + } + + return Ok(new OrganizationReportResponseModel(report)); } if (!await _currentContext.AccessReports(organizationId)) @@ -338,81 +265,59 @@ public async Task UpdateOrganizationReportDataAsync( throw new BadRequestException("Organization ID in the request body must match the route parameter"); } - if (request.ReportId != reportId) + var v1Request = new UpdateOrganizationReportRequest { - throw new BadRequestException("Report ID in the request body must match the route parameter"); - } + ReportId = reportId, + OrganizationId = organizationId, + ReportData = request.ReportData, + ContentEncryptionKey = request.ContentEncryptionKey, + SummaryData = request.SummaryData, + ApplicationData = request.ApplicationData + }; - var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request); + var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(v1Request); var response = new OrganizationReportResponseModel(updatedReport); - return Ok(response); } - [HttpGet("{organizationId}/data/application/{reportId}")] - public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) + /// + /// Gets summary data for organization reports within a specified date range. + /// The response is optimized for widget display by returning up to 6 entries that are + /// evenly spaced across the date range, including the most recent entry. + /// This allows the widget to show trends over time while ensuring the latest data point is always included. + /// + /// + /// + /// + /// + [HttpGet("{organizationId}/data/summary")] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync( + Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) { - try - { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } - - var applicationData = await _getOrganizationReportApplicationDataQuery.GetOrganizationReportApplicationDataAsync(organizationId, reportId); - - if (applicationData == null) - { - throw new NotFoundException("Organization report application data not found."); - } - - return Ok(applicationData); - } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + if (!await _currentContext.AccessReports(organizationId)) { - throw; + throw new NotFoundException(); } - } - [HttpPatch("{organizationId}/data/application/{reportId}")] - public async Task UpdateOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportApplicationDataRequest request) - { - try + if (organizationId == Guid.Empty) { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } - - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - - if (request.Id != reportId) - { - throw new BadRequestException("Report ID in the request body must match the route parameter"); - } + throw new BadRequestException("Organization ID is required."); + } - var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request); - var response = new OrganizationReportResponseModel(updatedReport); + var summaryDataList = await _getOrganizationReportSummaryDataByDateRangeQuery + .GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); - return Ok(response); - } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) - { - throw; - } + return Ok(summaryDataList); } - [RequireFeature(FeatureFlagKeys.WholeReportDataFileStorage)] + [RequireFeature(FeatureFlagKeys.AccessIntelligenceVersion2)] [HttpPost("{organizationId}/{reportId}/file/report-data")] [SelfHosted(SelfHostedOnly = true)] [RequestSizeLimit(Constants.FileSize501mb)] [DisableFormValueModelBinding] - public async Task UploadReportDataAsync(Guid organizationId, Guid reportId, [FromQuery] string reportFileId) + public async Task UploadReportFileAsync(Guid organizationId, Guid reportId, [FromQuery] string reportFileId) { - await AuthorizeV2Async(organizationId); + await AuthorizeAsync(organizationId); if (!Request?.ContentType?.Contains("multipart/") ?? true) { @@ -430,7 +335,7 @@ public async Task UploadReportDataAsync(Guid organizationId, Guid reportId, [Fro throw new BadRequestException("Invalid report ID"); } - var fileData = report.GetReportFileData(); + var fileData = report.GetReportFile(); if (fileData == null || fileData.Id != reportFileId) { throw new NotFoundException(); @@ -441,7 +346,10 @@ await Request.GetFileAsync(async (stream) => await _storageService.UploadReportDataAsync(report, fileData, stream); }); - var (valid, length) = await _storageService.ValidateFileAsync(report, fileData, 0, Constants.FileSize501mb); + var leeway = 1024L * 1024L; // 1 MB + var minimum = Math.Max(0, fileData.Size - leeway); + var maximum = Math.Min(fileData.Size + leeway, Constants.FileSize501mb); + var (valid, length) = await _storageService.ValidateFileAsync(report, fileData, minimum, maximum); if (!valid) { throw new BadRequestException("File received does not match expected constraints."); @@ -449,13 +357,13 @@ await Request.GetFileAsync(async (stream) => fileData.Validated = true; fileData.Size = length; - report.SetReportFileData(fileData); + report.SetReportFile(fileData); report.RevisionDate = DateTime.UtcNow; await _organizationReportRepo.ReplaceAsync(report); } [AllowAnonymous] - [RequireFeature(FeatureFlagKeys.WholeReportDataFileStorage)] + [RequireFeature(FeatureFlagKeys.AccessIntelligenceVersion2)] [HttpPost("file/validate/azure")] public async Task AzureValidateFile() { @@ -480,7 +388,7 @@ public async Task AzureValidateFile() return; } - var fileData = report.GetReportFileData(); + var fileData = report.GetReportFile(); if (fileData == null) { return; @@ -498,7 +406,7 @@ public async Task AzureValidateFile() }); } - private async Task AuthorizeV2Async(Guid organizationId) + private async Task AuthorizeAsync(Guid organizationId) { if (!await _currentContext.AccessReports(organizationId)) { @@ -511,4 +419,148 @@ private async Task AuthorizeV2Async(Guid organizationId) throw new BadRequestException("Your organization's plan does not support this feature."); } } + + // Removing post v2 launch + [HttpPatch("{organizationId}/data/application/{reportId}")] + public async Task UpdateOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportApplicationDataRequest request) + { + try + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.Id != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request); + var response = new OrganizationReportResponseModel(updatedReport); + + return Ok(response); + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + throw; + } + } + + [HttpGet("{organizationId}/data/application/{reportId}")] + public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) + { + try + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var applicationData = await _getOrganizationReportApplicationDataQuery.GetOrganizationReportApplicationDataAsync(organizationId, reportId); + + if (applicationData == null) + { + throw new NotFoundException("Organization report application data not found."); + } + + return Ok(applicationData); + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + throw; + } + } + + [HttpPatch("{organizationId}/data/report/{reportId}")] + public async Task UpdateOrganizationReportDataAsync( + Guid organizationId, + Guid reportId, + [FromBody] UpdateOrganizationReportDataRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.ReportId != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request); + var response = new OrganizationReportResponseModel(updatedReport); + + return Ok(response); + } + + [HttpGet("{organizationId}/data/report/{reportId}")] + public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var reportData = await _getOrganizationReportDataQuery.GetOrganizationReportDataAsync(organizationId, reportId); + + if (reportData == null) + { + throw new NotFoundException("Organization report data not found."); + } + + return Ok(reportData); + } + + [HttpPatch("{organizationId}/data/summary/{reportId}")] + public async Task UpdateOrganizationReportSummaryAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportSummaryRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.ReportId != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request); + var response = new OrganizationReportResponseModel(updatedReport); + + return Ok(response); + } + + [HttpGet("{organizationId}/data/summary/{reportId}")] + public async Task GetOrganizationReportSummaryAsync(Guid organizationId, Guid reportId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var summaryData = + await _getOrganizationReportSummaryDataQuery.GetOrganizationReportSummaryDataAsync(organizationId, reportId); + + if (summaryData == null) + { + throw new NotFoundException("Report not found for the specified organization."); + } + + return Ok(summaryData); + } } diff --git a/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportFileResponseModel.cs similarity index 56% rename from src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs rename to src/Api/Dirt/Models/Response/OrganizationReportFileResponseModel.cs index 63f73d07b4b8..c6ac4607ebfe 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportFileResponseModel.cs @@ -2,11 +2,11 @@ namespace Bit.Api.Dirt.Models.Response; -public class OrganizationReportV2ResponseModel +public class OrganizationReportFileResponseModel { - public OrganizationReportV2ResponseModel() { } + public OrganizationReportFileResponseModel() { } - public string ReportDataUploadUrl { get; set; } = string.Empty; + public string ReportFileUploadUrl { get; set; } = string.Empty; public OrganizationReportResponseModel ReportResponse { get; set; } = null!; public FileUploadType FileUploadType { get; set; } } diff --git a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs index c210303c5b2e..b457476e65a2 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs @@ -14,9 +14,10 @@ public class OrganizationReportResponseModel public int? PasswordCount { get; set; } public int? PasswordAtRiskCount { get; set; } public int? MemberCount { get; set; } - public ReportFile? File { get; set; } - public DateTime? CreationDate { get; set; } = null; - public DateTime? RevisionDate { get; set; } = null; + public ReportFile? ReportFile { get; set; } + public string? ReportFileDownloadUrl { get; set; } + public DateTime? CreationDate { get; set; } + public DateTime? RevisionDate { get; set; } public OrganizationReportResponseModel(OrganizationReport organizationReport) { @@ -34,6 +35,7 @@ public OrganizationReportResponseModel(OrganizationReport organizationReport) PasswordCount = organizationReport.PasswordCount; PasswordAtRiskCount = organizationReport.PasswordAtRiskCount; MemberCount = organizationReport.MemberCount; + ReportFile = organizationReport.GetReportFile(); CreationDate = organizationReport.CreationDate; RevisionDate = organizationReport.RevisionDate; } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8a642be11dbd..7cc92d4a6d34 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -268,7 +268,7 @@ public static class FeatureFlagKeys public const string ArchiveVaultItems = "pm-19148-innovation-archive"; /* DIRT Team */ - public const string WholeReportDataFileStorage = "pm-31920-whole-report-data-file-storage"; + public const string AccessIntelligenceVersion2 = "pm-31920-whole-report-data-file-storage"; public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging"; public const string EventManagementForHuntress = "event-management-for-huntress"; diff --git a/src/Core/Dirt/Entities/OrganizationReport.cs b/src/Core/Dirt/Entities/OrganizationReport.cs index b25c927eff1e..098573dc34f3 100644 --- a/src/Core/Dirt/Entities/OrganizationReport.cs +++ b/src/Core/Dirt/Entities/OrganizationReport.cs @@ -27,21 +27,21 @@ public class OrganizationReport : ITableObject public int? PasswordAtRiskCount { get; set; } public int? CriticalPasswordCount { get; set; } public int? CriticalPasswordAtRiskCount { get; set; } - public ReportFile? OrganizationReportFile { get; set; } + public string? ReportFile { get; set; } - public ReportFile? GetReportFileData() + public ReportFile? GetReportFile() { - if (string.IsNullOrWhiteSpace(ReportData)) + if (string.IsNullOrWhiteSpace(ReportFile)) { return null; } - return JsonSerializer.Deserialize(ReportData); + return JsonSerializer.Deserialize(ReportFile); } - public void SetReportFileData(ReportFile data) + public void SetReportFile(ReportFile data) { - ReportData = JsonSerializer.Serialize(data, JsonHelpers.IgnoreWritingNull); + ReportFile = JsonSerializer.Serialize(data, JsonHelpers.IgnoreWritingNull); } public void SetNewId() diff --git a/src/Core/Dirt/Models/Data/OrganizationReportDataFileStorageResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportDataFileStorageResponse.cs deleted file mode 100644 index 8af6799810e0..000000000000 --- a/src/Core/Dirt/Models/Data/OrganizationReportDataFileStorageResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bit.Core.Dirt.Models.Data; - -public class OrganizationReportDataFileStorageResponse -{ - public string DownloadUrl { get; set; } = string.Empty; -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportCommand.cs similarity index 91% rename from src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs rename to src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportCommand.cs index 135e4b09dab6..3eeac7518c02 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportCommand.cs @@ -10,16 +10,16 @@ namespace Bit.Core.Dirt.Reports.ReportFeatures; -public class CreateOrganizationReportV2Command : ICreateOrganizationReportV2Command +public class CreateOrganizationReportCommand : ICreateOrganizationReportCommand { private readonly IOrganizationRepository _organizationRepo; private readonly IOrganizationReportRepository _organizationReportRepo; - private readonly ILogger _logger; + private readonly ILogger _logger; - public CreateOrganizationReportV2Command( + public CreateOrganizationReportCommand( IOrganizationRepository organizationRepository, IOrganizationReportRepository organizationReportRepository, - ILogger logger) + ILogger logger) { _organizationRepo = organizationRepository; _organizationReportRepo = organizationReportRepository; @@ -44,6 +44,7 @@ public async Task CreateAsync(AddOrganizationReportRequest r { Id = CoreHelpers.SecureRandomString(32, upper: false, special: false), FileName = "report-data.json", + Size = request.FileSize ?? 0, Validated = false }; @@ -68,7 +69,7 @@ public async Task CreateAsync(AddOrganizationReportRequest r CriticalPasswordAtRiskCount = request.ReportMetrics?.CriticalPasswordAtRiskCount, RevisionDate = DateTime.UtcNow }; - organizationReport.SetReportFileData(fileData); + organizationReport.SetReportFile(fileData); var data = await _organizationReportRepo.CreateAsync(organizationReport); diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs deleted file mode 100644 index 2e231d7f073e..000000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Bit.Core.Dirt.Models.Data; -using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; -using Bit.Core.Dirt.Reports.Services; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Dirt.Reports.ReportFeatures; - -public class GetOrganizationReportDataV2Query : IGetOrganizationReportDataV2Query -{ - private readonly IOrganizationReportRepository _organizationReportRepo; - private readonly IOrganizationReportStorageService _storageService; - private readonly ILogger _logger; - - public GetOrganizationReportDataV2Query( - IOrganizationReportRepository organizationReportRepo, - IOrganizationReportStorageService storageService, - ILogger logger) - { - _organizationReportRepo = organizationReportRepo; - _storageService = storageService; - _logger = logger; - } - - public async Task GetOrganizationReportDataAsync( - Guid organizationId, - Guid reportId, - string reportFileId) - { - _logger.LogInformation(Constants.BypassFiltersEventId, - "Generating download URL for report data - organization {organizationId}, report {reportId}", - organizationId, reportId); - - if (string.IsNullOrEmpty(reportFileId)) - { - throw new BadRequestException("ReportFileId is required"); - } - - var report = await _organizationReportRepo.GetByIdAsync(reportId); - if (report == null || report.OrganizationId != organizationId) - { - throw new NotFoundException("Report not found"); - } - - var fileData = report.GetReportFileData(); - if (fileData == null) - { - throw new NotFoundException("Report file data not found"); - } - - var downloadUrl = await _storageService.GetReportDataDownloadUrlAsync(report, fileData); - - return new OrganizationReportDataFileStorageResponse { DownloadUrl = downloadUrl }; - } -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportCommand.cs similarity index 81% rename from src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportV2Command.cs rename to src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportCommand.cs index 04a2ac5d1812..b090dd12d609 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportV2Command.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportCommand.cs @@ -3,7 +3,7 @@ namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; -public interface ICreateOrganizationReportV2Command +public interface ICreateOrganizationReportCommand { Task CreateAsync(AddOrganizationReportRequest request); } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataV2Query.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataV2Query.cs deleted file mode 100644 index e67ec0dec35c..000000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataV2Query.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Core.Dirt.Models.Data; - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; - -public interface IGetOrganizationReportDataV2Query -{ - Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId, string reportFileId); -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataV2Command.cs deleted file mode 100644 index 21d9f005e9dc..000000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataV2Command.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; - -public interface IUpdateOrganizationReportDataV2Command -{ - Task GetUploadUrlAsync(UpdateOrganizationReportDataRequest request, string reportFileId); -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportV2Command.cs new file mode 100644 index 000000000000..a67c7c725d5f --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportV2Command.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportV2Command +{ + Task UpdateAsync(UpdateOrganizationReportV2Request request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index c11575749627..4e1bc0a84beb 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -29,11 +29,8 @@ public static void AddReportingServices(this IServiceCollection services, IGloba services.AddScoped(); // v2 file storage commands - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); - - // v2 file storage queries - services.AddScoped(); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs index f49f9a7fc204..3335ce6cd845 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs @@ -12,4 +12,9 @@ public class AddOrganizationReportRequest public string? ApplicationData { get; set; } public OrganizationReportMetrics? ReportMetrics { get; set; } + + /// + /// Estimated size of the report file in bytes. Required for v2 reports. + /// + public long? FileSize { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs new file mode 100644 index 000000000000..7ec4f76a2ffe --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs @@ -0,0 +1,13 @@ +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportV2Request +{ + public Guid ReportId { get; set; } + public Guid OrganizationId { get; set; } + public string? ReportData { get; set; } + public string? ContentEncryptionKey { get; set; } + public string? SummaryData { get; set; } + public string? ApplicationData { get; set; } + public OrganizationReportMetrics? ReportMetrics { get; set; } + public bool RequiresNewFileUpload { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs deleted file mode 100644 index f4d6bbc85299..000000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -using Bit.Core.Dirt.Reports.Services; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Dirt.Reports.ReportFeatures; - -public class UpdateOrganizationReportDataV2Command : IUpdateOrganizationReportDataV2Command -{ - private readonly IOrganizationReportRepository _organizationReportRepo; - private readonly IOrganizationReportStorageService _storageService; - private readonly ILogger _logger; - - public UpdateOrganizationReportDataV2Command( - IOrganizationReportRepository organizationReportRepository, - IOrganizationReportStorageService storageService, - ILogger logger) - { - _organizationReportRepo = organizationReportRepository; - _storageService = storageService; - _logger = logger; - } - - public async Task GetUploadUrlAsync(UpdateOrganizationReportDataRequest request, string reportFileId) - { - _logger.LogInformation(Constants.BypassFiltersEventId, - "Generating upload URL for report data - organization {organizationId}, report {reportId}", - request.OrganizationId, request.ReportId); - - var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); - if (existingReport == null || existingReport.OrganizationId != request.OrganizationId) - { - throw new NotFoundException("Report not found"); - } - - var fileData = existingReport.GetReportFileData(); - if (fileData == null || fileData.Id != reportFileId) - { - throw new NotFoundException("Report not found"); - } - - // Update revision date - existingReport.RevisionDate = DateTime.UtcNow; - await _organizationReportRepo.ReplaceAsync(existingReport); - - return await _storageService.GetReportDataUploadUrlAsync(existingReport, fileData); - } -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs new file mode 100644 index 000000000000..fb5ff4a0daac --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs @@ -0,0 +1,134 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportV2Command : IUpdateOrganizationReportV2Command +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportV2Command( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateAsync(UpdateOrganizationReportV2Request request) + { + _logger.LogInformation(Constants.BypassFiltersEventId, + "Updating v2 organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, + "Failed to update v2 organization report {reportId} for organization {organizationId}: {errorMessage}", + request.ReportId, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, + "Organization report {reportId} not found", request.ReportId); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, + "Organization report {reportId} does not belong to organization {organizationId}", + request.ReportId, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + if (request.ContentEncryptionKey != null) + { + existingReport.ContentEncryptionKey = request.ContentEncryptionKey; + } + + if (request.SummaryData != null) + { + existingReport.SummaryData = request.SummaryData; + } + + if (request.ApplicationData != null) + { + existingReport.ApplicationData = request.ApplicationData; + } + + if (request.ReportMetrics != null) + { + existingReport.ApplicationCount = request.ReportMetrics.ApplicationCount; + existingReport.ApplicationAtRiskCount = request.ReportMetrics.ApplicationAtRiskCount; + existingReport.CriticalApplicationCount = request.ReportMetrics.CriticalApplicationCount; + existingReport.CriticalApplicationAtRiskCount = request.ReportMetrics.CriticalApplicationAtRiskCount; + existingReport.MemberCount = request.ReportMetrics.MemberCount; + existingReport.MemberAtRiskCount = request.ReportMetrics.MemberAtRiskCount; + existingReport.CriticalMemberCount = request.ReportMetrics.CriticalMemberCount; + existingReport.CriticalMemberAtRiskCount = request.ReportMetrics.CriticalMemberAtRiskCount; + existingReport.PasswordCount = request.ReportMetrics.PasswordCount; + existingReport.PasswordAtRiskCount = request.ReportMetrics.PasswordAtRiskCount; + existingReport.CriticalPasswordCount = request.ReportMetrics.CriticalPasswordCount; + existingReport.CriticalPasswordAtRiskCount = request.ReportMetrics.CriticalPasswordAtRiskCount; + } + + if (request.RequiresNewFileUpload) + { + var fileData = new ReportFile + { + Id = CoreHelpers.SecureRandomString(32, upper: false, special: false), + FileName = "report-data.json", + Validated = false, + Size = 0 + }; + existingReport.SetReportFile(fileData); + } + + existingReport.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(existingReport); + + _logger.LogInformation(Constants.BypassFiltersEventId, + "Successfully updated v2 organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + return existingReport; + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync( + UpdateOrganizationReportV2Request request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.ReportId == Guid.Empty) + { + return (false, "ReportId is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs index a78c6880608d..afb4d0f976d0 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs @@ -24,7 +24,7 @@ public ValidateOrganizationReportFileCommand( public async Task ValidateAsync(OrganizationReport report, string reportFileId) { - var fileData = report.GetReportFileData(); + var fileData = report.GetReportFile(); if (fileData == null || fileData.Id != reportFileId) { return false; @@ -43,7 +43,7 @@ public async Task ValidateAsync(OrganizationReport report, string reportFi fileData.Validated = true; fileData.Size = length; - report.SetReportFileData(fileData); + report.SetReportFile(fileData); report.RevisionDate = DateTime.UtcNow; await _organizationReportRepo.ReplaceAsync(report); return true; diff --git a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs index 4580b7c1fbd1..faa8c85c93c8 100644 --- a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs @@ -30,7 +30,7 @@ public AzureOrganizationReportStorageService( _logger = logger; } - public async Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData) + public async Task GetReportFileUploadUrlAsync(OrganizationReport report, ReportFile fileData) { await InitAsync(); var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); diff --git a/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs index bae2eb793aee..888933f9d400 100644 --- a/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs @@ -8,7 +8,7 @@ public interface IOrganizationReportStorageService { FileUploadType FileUploadType { get; } - Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData); + Task GetReportFileUploadUrlAsync(OrganizationReport report, ReportFile fileData); Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData); diff --git a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs index 98a07d86006d..502a66140754 100644 --- a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs @@ -18,7 +18,7 @@ public LocalOrganizationReportStorageService(GlobalSettings globalSettings) _baseUrl = globalSettings.OrganizationReport.BaseUrl; } - public Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData) + public Task GetReportFileUploadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult($"/reports/organizations/{report.OrganizationId}/{report.Id}/file/report-data"); public Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData) diff --git a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs index 18e0a363e01f..c9260914008d 100644 --- a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs @@ -8,7 +8,7 @@ public class NoopOrganizationReportStorageService : IOrganizationReportStorageSe { public FileUploadType FileUploadType => FileUploadType.Direct; - public Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult(string.Empty); + public Task GetReportFileUploadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult(string.Empty); public Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult(string.Empty); diff --git a/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs index 2de0a6d0c99b..00062a799d09 100644 --- a/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs +++ b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs @@ -10,6 +10,7 @@ public class OrganizationReportResponseModelTests [Theory, BitAutoData] public void Constructor_MapsPropertiesFromEntity(OrganizationReport report) { + report.ReportFile = null; var model = new OrganizationReportResponseModel(report); Assert.Equal(report.Id, model.Id); @@ -28,8 +29,9 @@ public void Constructor_MapsPropertiesFromEntity(OrganizationReport report) [Theory, BitAutoData] public void Constructor_FileIsNull(OrganizationReport report) { + report.ReportFile = null; var model = new OrganizationReportResponseModel(report); - Assert.Null(model.File); + Assert.Null(model.ReportFile); } } diff --git a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs index cf8c233179ba..f179524f782c 100644 --- a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs +++ b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs @@ -1,11 +1,16 @@ using Bit.Api.Dirt.Controllers; using Bit.Api.Dirt.Models.Response; +using Bit.Core; using Bit.Core.Context; using Bit.Core.Dirt.Entities; using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Mvc; @@ -18,15 +23,21 @@ namespace Bit.Api.Test.Dirt; [SutProviderCustomize] public class OrganizationReportControllerTests { - #region Whole OrganizationReport Endpoints + // GetLatestOrganizationReportAsync - V1 (flag off) [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithValidOrgId_ReturnsOkResult( + public async Task GetLatestOrganizationReportAsync_V1_WithValidOrgId_ReturnsOkResult( SutProvider sutProvider, Guid orgId, OrganizationReport expectedReport) { // Arrange + expectedReport.ReportFile = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -45,31 +56,38 @@ public async Task GetLatestOrganizationReportAsync_WithValidOrgId_ReturnsOkResul } [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task GetLatestOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) - .Returns(Task.FromResult(false)); + .Returns(false); // Act & Assert await Assert.ThrowsAsync(() => sutProvider.Sut.GetLatestOrganizationReportAsync(orgId)); - // Verify that the query was not called await sutProvider.GetDependency() .DidNotReceive() .GetLatestOrganizationReportAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_WhenNoReportFound_ReturnsOkWithNull( + public async Task GetLatestOrganizationReportAsync_V1_WhenNoReportFound_ReturnsOkWithNull( SutProvider sutProvider, Guid orgId) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -86,39 +104,352 @@ public async Task GetLatestOrganizationReportAsync_WhenNoReportFound_ReturnsOkWi Assert.Null(okResult.Value); } + // GetLatestOrganizationReportAsync - V2 (flag on) + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_V2_WithValidatedFile_ReturnsOkWithDownloadUrl( + SutProvider sutProvider, + Guid orgId, + OrganizationReport expectedReport, + string downloadUrl) + { + // Arrange + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + expectedReport.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns(expectedReport); + + sutProvider.GetDependency() + .GetReportDataDownloadUrlAsync(expectedReport, Arg.Any()) + .Returns(downloadUrl); + + // Act + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Equal(downloadUrl, response.ReportFileDownloadUrl); + } + [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_CallsCorrectMethods( + public async Task GetLatestOrganizationReportAsync_V2_WithNoFile_ReturnsOkWithNullDownloadUrl( SutProvider sutProvider, Guid orgId, OrganizationReport expectedReport) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + expectedReport.ReportFile = null; + + SetupV2Authorization(sutProvider, orgId); sutProvider.GetDependency() .GetLatestOrganizationReportAsync(orgId) .Returns(expectedReport); // Act - await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Null(response.ReportFileDownloadUrl); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_V2_NoReport_ReturnsOkWithNull( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns((OrganizationReport)null); + + // Act + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Null(okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetLatestOrganizationReportAsync(orgId)); await sutProvider.GetDependency() - .Received(1) - .GetLatestOrganizationReportAsync(orgId); + .DidNotReceive() + .GetLatestOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_V2_NoUseRiskInsights_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(new OrganizationAbility { UseRiskInsights = false }); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetLatestOrganizationReportAsync(orgId)); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetLatestOrganizationReportAsync(Arg.Any()); + } + + // CreateOrganizationReportAsync - V1 (flag off) + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V1_WithValidRequest_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + expectedReport.ReportFile = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .AddOrganizationReportAsync(request) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + + // Assert + var okResult = Assert.IsType(result); + var expectedResponse = new OrganizationReportResponseModel(expectedReport); + Assert.Equivalent(expectedResponse, okResult.Value); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + await sutProvider.GetDependency() + .DidNotReceive() + .AddOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V1_WithMismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + + await sutProvider.GetDependency() + .DidNotReceive() + .AddOrganizationReportAsync(Arg.Any()); + } + + // CreateOrganizationReportAsync - V2 (flag on) + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_WithValidRequest_ReturnsFileResponseModel( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request, + OrganizationReport expectedReport, + string uploadUrl) + { + // Arrange + request.OrganizationId = orgId; + request.FileSize = 1024; + + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false }; + expectedReport.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .CreateAsync(request) + .Returns(expectedReport); + + sutProvider.GetDependency() + .GetReportFileUploadUrlAsync(expectedReport, Arg.Any()) + .Returns(uploadUrl); + + sutProvider.GetDependency() + .FileUploadType + .Returns(FileUploadType.Azure); + + // Act + var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Equal(uploadUrl, response.ReportFileUploadUrl); + Assert.Equal(FileUploadType.Azure, response.FileUploadType); + Assert.NotNull(response.ReportResponse); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_EmptyOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + AddOrganizationReportRequest request) + { + // Arrange + var emptyOrgId = Guid.Empty; + request.OrganizationId = emptyOrgId; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(emptyOrgId, request)); + + Assert.Equal("Organization ID is required.", exception.Message); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_MismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_MissingFileSize_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = orgId; + request.FileSize = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + Assert.Equal("File size is required.", exception.Message); } + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = orgId; + request.FileSize = 1024; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + await sutProvider.GetDependency() + .DidNotReceive() + .CreateAsync(Arg.Any()); + } + // GetOrganizationReportAsync - V1 (flag off) [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WithValidIds_ReturnsOkResult( + public async Task GetOrganizationReportAsync_V1_WithValidIds_ReturnsOkResult( SutProvider sutProvider, Guid orgId, Guid reportId, @@ -126,6 +457,11 @@ public async Task GetOrganizationReportAsync_WithValidIds_ReturnsOkResult( { // Arrange expectedReport.OrganizationId = orgId; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -143,33 +479,40 @@ public async Task GetOrganizationReportAsync_WithValidIds_ReturnsOkResult( } [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task GetOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, Guid reportId) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) - .Returns(Task.FromResult(false)); + .Returns(false); // Act & Assert await Assert.ThrowsAsync(() => sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - // Verify that the query was not called await sutProvider.GetDependency() .DidNotReceive() .GetOrganizationReportAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WhenReportNotFound_ThrowsNotFoundException( + public async Task GetOrganizationReportAsync_V1_WhenReportNotFound_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, Guid reportId) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -186,14 +529,19 @@ public async Task GetOrganizationReportAsync_WhenReportNotFound_ThrowsNotFoundEx } [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_CallsCorrectMethods( + public async Task GetOrganizationReportAsync_V1_WithOrgMismatch_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, Guid reportId, OrganizationReport expectedReport) { // Arrange - expectedReport.OrganizationId = orgId; + expectedReport.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -202,205 +550,198 @@ public async Task GetOrganizationReportAsync_CallsCorrectMethods( .GetOrganizationReportAsync(reportId) .Returns(expectedReport); - // Act - await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - await sutProvider.GetDependency() - .Received(1) - .GetOrganizationReportAsync(reportId); + Assert.Equal("Invalid report ID", exception.Message); } + // GetOrganizationReportAsync - V2 (flag on) + [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WithValidAccess_UsesCorrectReportId( + public async Task GetOrganizationReportAsync_V2_WithValidatedFile_ReturnsOkWithDownloadUrl( SutProvider sutProvider, Guid orgId, Guid reportId, - OrganizationReport expectedReport) + OrganizationReport expectedReport, + string downloadUrl) { // Arrange expectedReport.OrganizationId = orgId; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + expectedReport.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); sutProvider.GetDependency() .GetOrganizationReportAsync(reportId) .Returns(expectedReport); + sutProvider.GetDependency() + .GetReportDataDownloadUrlAsync(expectedReport, Arg.Any()) + .Returns(downloadUrl); + // Act - await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); + var result = await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); // Assert - await sutProvider.GetDependency() - .Received(1) - .GetOrganizationReportAsync(reportId); + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Equal(downloadUrl, response.ReportFileDownloadUrl); } [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_WithValidRequest_ReturnsOkResult( + public async Task GetOrganizationReportAsync_V2_WithNoFile_ReturnsOkWithoutDownloadUrl( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request, + Guid reportId, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; + expectedReport.OrganizationId = orgId; + expectedReport.ReportFile = null; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupV2Authorization(sutProvider, orgId); - sutProvider.GetDependency() - .AddOrganizationReportAsync(request) + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) .Returns(expectedReport); // Act - var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + var result = await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); // Assert var okResult = Assert.IsType(result); - var expectedResponse = new OrganizationReportResponseModel(expectedReport); - Assert.Equivalent(expectedResponse, okResult.Value); + var response = Assert.IsType(okResult.Value); + Assert.Null(response.ReportFileDownloadUrl); } [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task GetOrganizationReportAsync_V2_WithOrgMismatch_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request) + Guid reportId, + OrganizationReport expectedReport) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(false); - - // Act & Assert - await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + expectedReport.OrganizationId = Guid.NewGuid(); - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .AddOrganizationReportAsync(Arg.Any()); - } + SetupV2Authorization(sutProvider, orgId); - [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadRequestException( - SutProvider sutProvider, - Guid orgId, - AddOrganizationReportRequest request) - { - // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Returns(expectedReport); // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); - - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .AddOrganizationReportAsync(Arg.Any()); + Assert.Equal("Invalid report ID", exception.Message); } [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_CallsCorrectMethods( + public async Task GetOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request, - OrganizationReport expectedReport) + Guid reportId) { // Arrange - request.OrganizationId = orgId; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); sutProvider.GetDependency() .AccessReports(orgId) - .Returns(true); - - sutProvider.GetDependency() - .AddOrganizationReportAsync(request) - .Returns(expectedReport); - - // Act - await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + .Returns(false); - // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - await sutProvider.GetDependency() - .Received(1) - .AddOrganizationReportAsync(request); + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationReportAsync(Arg.Any()); } + // UpdateOrganizationReportAsync - V1 (flag off) + [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_WithValidRequest_ReturnsOkResult( + public async Task UpdateOrganizationReportAsync_V1_WithValidRequest_ReturnsOkResult( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request, + Guid reportId, + UpdateOrganizationReportV2Request request, OrganizationReport expectedReport) { // Arrange request.OrganizationId = orgId; + expectedReport.ReportFile = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); sutProvider.GetDependency() - .UpdateOrganizationReportAsync(request) + .UpdateOrganizationReportAsync(Arg.Any()) .Returns(expectedReport); // Act - var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request); + var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request); // Assert var okResult = Assert.IsType(result); var expectedResponse = new OrganizationReportResponseModel(expectedReport); Assert.Equivalent(expectedResponse, okResult.Value); + + await sutProvider.GetDependency() + .Received(1) + .UpdateOrganizationReportAsync(Arg.Is(r => + r.OrganizationId == orgId && r.ReportId == reportId)); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task UpdateOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request) + Guid reportId, + UpdateOrganizationReportV2Request request) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(false); // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request)); + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request)); - // Verify that the command was not called await sutProvider.GetDependency() .DidNotReceive() .UpdateOrganizationReportAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadRequestException( + public async Task UpdateOrganizationReportAsync_V1_WithMismatchedOrgId_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request) + Guid reportId, + UpdateOrganizationReportV2Request request) { // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId + request.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); sutProvider.GetDependency() .AccessReports(orgId) @@ -408,50 +749,113 @@ public async Task UpdateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadReq // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request)); + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request)); Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); - // Verify that the command was not called await sutProvider.GetDependency() .DidNotReceive() .UpdateOrganizationReportAsync(Arg.Any()); } + // UpdateOrganizationReportAsync - V2 (flag on) + [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_CallsCorrectMethods( + public async Task UpdateOrganizationReportAsync_V2_NoNewFileUpload_ReturnsReportResponseModel( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request, + Guid reportId, + UpdateOrganizationReportV2Request request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; + request.RequiresNewFileUpload = false; + expectedReport.ReportFile = null; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupV2Authorization(sutProvider, orgId); - sutProvider.GetDependency() - .UpdateOrganizationReportAsync(request) + sutProvider.GetDependency() + .UpdateAsync(request) .Returns(expectedReport); // Act - await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request); + var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request); // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + var okResult = Assert.IsType(result); + Assert.IsType(okResult.Value); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .UpdateOrganizationReportAsync(request); + .UpdateAsync(request); } - #endregion + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_V2_WithNewFileUpload_ReturnsFileResponseModel( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportV2Request request, + OrganizationReport expectedReport, + string uploadUrl) + { + // Arrange + request.RequiresNewFileUpload = true; + + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false }; + expectedReport.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); - #region SummaryData Field Endpoints + sutProvider.GetDependency() + .UpdateAsync(request) + .Returns(expectedReport); + + sutProvider.GetDependency() + .GetReportFileUploadUrlAsync(expectedReport, Arg.Any()) + .Returns(uploadUrl); + + sutProvider.GetDependency() + .FileUploadType + .Returns(FileUploadType.Azure); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Equal(uploadUrl, response.ReportFileUploadUrl); + Assert.Equal(FileUploadType.Azure, response.FileUploadType); + Assert.NotNull(response.ReportResponse); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportV2Request request) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request)); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateAsync(Arg.Any()); + } + + // SummaryData Field Endpoints [Theory, BitAutoData] public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithValidParameters_ReturnsOkResult( @@ -587,6 +991,7 @@ public async Task UpdateOrganizationReportSummaryAsync_WithValidRequest_ReturnsO // Arrange request.OrganizationId = orgId; request.ReportId = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -692,6 +1097,7 @@ public async Task UpdateOrganizationReportSummaryAsync_CallsCorrectMethods( // Arrange request.OrganizationId = orgId; request.ReportId = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -714,9 +1120,7 @@ await sutProvider.GetDependency() .UpdateOrganizationReportSummaryAsync(request); } - #endregion - - #region ReportData Field Endpoints + // ReportData Field Endpoints [Theory, BitAutoData] public async Task GetOrganizationReportDataAsync_WithValidIds_ReturnsOkResult( @@ -803,6 +1207,7 @@ public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ReturnsOkRe // Arrange request.OrganizationId = orgId; request.ReportId = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -813,7 +1218,7 @@ public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ReturnsOkRe .Returns(expectedReport); // Act - var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null); + var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); // Assert var okResult = Assert.IsType(result); @@ -835,7 +1240,7 @@ public async Task UpdateOrganizationReportDataAsync_WithoutAccess_ThrowsNotFound // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null)); + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); // Verify that the command was not called await sutProvider.GetDependency() @@ -860,7 +1265,7 @@ public async Task UpdateOrganizationReportDataAsync_WithMismatchedOrgId_ThrowsBa // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null)); + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); @@ -887,7 +1292,7 @@ public async Task UpdateOrganizationReportDataAsync_WithMismatchedReportId_Throw // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null)); + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); @@ -908,6 +1313,7 @@ public async Task UpdateOrganizationReportDataAsync_CallsCorrectMethods( // Arrange request.OrganizationId = orgId; request.ReportId = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -918,7 +1324,7 @@ public async Task UpdateOrganizationReportDataAsync_CallsCorrectMethods( .Returns(expectedReport); // Act - await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null); + await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); // Assert await sutProvider.GetDependency() @@ -930,9 +1336,7 @@ await sutProvider.GetDependency() .UpdateOrganizationReportDataAsync(request); } - #endregion - - #region ApplicationData Field Endpoints + // ApplicationData Field Endpoints [Theory, BitAutoData] public async Task GetOrganizationReportApplicationDataAsync_WithValidIds_ReturnsOkResult( @@ -1042,6 +1446,7 @@ public async Task UpdateOrganizationReportApplicationDataAsync_WithValidRequest_ request.OrganizationId = orgId; request.Id = reportId; expectedReport.Id = request.Id; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -1146,6 +1551,7 @@ public async Task UpdateOrganizationReportApplicationDataAsync_CallsCorrectMetho request.OrganizationId = orgId; request.Id = reportId; expectedReport.Id = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -1168,5 +1574,22 @@ await sutProvider.GetDependency .UpdateOrganizationReportApplicationDataAsync(request); } - #endregion + // Helper method for setting up V2 authorization mocks + + private static void SetupV2Authorization( + SutProvider sutProvider, + Guid orgId) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(new OrganizationAbility { UseRiskInsights = true }); + } } diff --git a/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportCommandTests.cs similarity index 90% rename from test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs rename to test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportCommandTests.cs index 77c120dcde5e..f49d378519a8 100644 --- a/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportCommandTests.cs @@ -14,12 +14,12 @@ namespace Bit.Core.Test.Dirt.ReportFeatures; [SutProviderCustomize] -public class CreateOrganizationReportV2CommandTests +public class CreateOrganizationReportCommandTests { [Theory] [BitAutoData] public async Task CreateAsync_Success_ReturnsReportWithSerializedFileData( - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange var fixture = new Fixture(); @@ -40,9 +40,9 @@ public async Task CreateAsync_Success_ReturnsReportWithSerializedFileData( // Assert Assert.NotNull(report); - // ReportData should contain serialized ReportFile - Assert.NotEmpty(report.ReportData); - var fileData = report.GetReportFileData(); + // ReportFile should contain serialized file data + Assert.NotNull(report.ReportFile); + var fileData = report.GetReportFile(); Assert.NotNull(fileData); Assert.NotNull(fileData.Id); Assert.Equal(32, fileData.Id.Length); @@ -64,7 +64,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task CreateAsync_InvalidOrganization_ThrowsBadRequestException( - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange var fixture = new Fixture(); @@ -85,7 +85,7 @@ public async Task CreateAsync_InvalidOrganization_ThrowsBadRequestException( [Theory] [BitAutoData] public async Task CreateAsync_MissingContentEncryptionKey_ThrowsBadRequestException( - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange var fixture = new Fixture(); @@ -106,7 +106,7 @@ public async Task CreateAsync_MissingContentEncryptionKey_ThrowsBadRequestExcept [Theory] [BitAutoData] public async Task CreateAsync_WithMetrics_StoresMetricsCorrectly( - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange var fixture = new Fixture(); diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs deleted file mode 100644 index c0d80586511c..000000000000 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs +++ /dev/null @@ -1,149 +0,0 @@ -using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Models.Data; -using Bit.Core.Dirt.Reports.ReportFeatures; -using Bit.Core.Dirt.Reports.Services; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Dirt.ReportFeatures; - -[SutProviderCustomize] -public class GetOrganizationReportDataV2QueryTests -{ - private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid organizationId, string fileId) - { - var fileData = new ReportFile - { - Id = fileId, - FileName = "report-data.json", - Validated = true - }; - - var report = new OrganizationReport - { - Id = reportId, - OrganizationId = organizationId - }; - report.SetReportFileData(fileData); - return report; - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_Success_ReturnsDownloadUrl( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var reportId = Guid.NewGuid(); - var reportFileId = "test-file-id-plaintext"; - var expectedUrl = "https://blob.storage.azure.com/sas-url"; - - var report = CreateReportWithFileData(reportId, organizationId, "encrypted-file-id"); - - sutProvider.GetDependency() - .GetByIdAsync(reportId) - .Returns(report); - - sutProvider.GetDependency() - .GetReportDataDownloadUrlAsync(report, Arg.Any()) - .Returns(expectedUrl); - - // Act - var result = await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId); - - // Assert - Assert.NotNull(result); - Assert.Equal(expectedUrl, result.DownloadUrl); - - await sutProvider.GetDependency() - .Received(1) - .GetReportDataDownloadUrlAsync(report, Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_ReportNotFound_ThrowsNotFoundException( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var reportId = Guid.NewGuid(); - var reportFileId = "test-file-id"; - - sutProvider.GetDependency() - .GetByIdAsync(reportId) - .Returns(null as OrganizationReport); - - // Act & Assert - await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId)); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_OrganizationMismatch_ThrowsNotFoundException( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var differentOrgId = Guid.NewGuid(); - var reportId = Guid.NewGuid(); - var reportFileId = "test-file-id"; - - var report = CreateReportWithFileData(reportId, differentOrgId, "file-id"); - - sutProvider.GetDependency() - .GetByIdAsync(reportId) - .Returns(report); - - // Act & Assert - await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId)); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_MissingReportFileId_ThrowsBadRequestException( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var reportId = Guid.NewGuid(); - string? reportFileId = null; - - // Act & Assert - await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId!)); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_EmptyReportData_ThrowsNotFoundException( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var reportId = Guid.NewGuid(); - var reportFileId = "test-file-id"; - - var report = new OrganizationReport - { - Id = reportId, - OrganizationId = organizationId, - ReportData = string.Empty - }; - - sutProvider.GetDependency() - .GetByIdAsync(reportId) - .Returns(report); - - // Act & Assert - await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId)); - } -} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs deleted file mode 100644 index 0d7bcead329c..000000000000 --- a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using AutoFixture; -using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Models.Data; -using Bit.Core.Dirt.Reports.ReportFeatures; -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Dirt.ReportFeatures; - -[SutProviderCustomize] -public class UpdateOrganizationReportDataV2CommandTests -{ - private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid organizationId, string fileId) - { - var fileData = new ReportFile - { - Id = fileId, - FileName = "report-data.json", - Validated = false - }; - - var report = new OrganizationReport - { - Id = reportId, - OrganizationId = organizationId - }; - report.SetReportFileData(fileData); - return report; - } - - [Theory] - [BitAutoData] - public async Task GetUploadUrlAsync_WithMismatchedFileId_ShouldThrowNotFoundException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - var existingReport = CreateReportWithFileData(request.ReportId, request.OrganizationId, "stored-file-id"); - - sutProvider.GetDependency() - .GetByIdAsync(request.ReportId) - .Returns(existingReport); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.GetUploadUrlAsync(request, "attacker-supplied-file-id")); - - Assert.Equal("Report not found", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task GetUploadUrlAsync_WithNonExistentReport_ShouldThrowNotFoundException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - - sutProvider.GetDependency() - .GetByIdAsync(request.ReportId) - .Returns((OrganizationReport)null); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.GetUploadUrlAsync(request, "any-file-id")); - - Assert.Equal("Report not found", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task GetUploadUrlAsync_WithMismatchedOrgId_ShouldThrowNotFoundException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - var existingReport = CreateReportWithFileData(request.ReportId, Guid.NewGuid(), "file-id"); - - sutProvider.GetDependency() - .GetByIdAsync(request.ReportId) - .Returns(existingReport); - - // Act & Assert - await Assert.ThrowsAsync(async () => - await sutProvider.Sut.GetUploadUrlAsync(request, "any-file-id")); - } -} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs new file mode 100644 index 000000000000..1761c4060d4f --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs @@ -0,0 +1,309 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class UpdateOrganizationReportV2CommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateAsync_Success_UpdatesFieldsAndReturnsReport( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var existingReport = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, orgId) + .Without(r => r.ReportFile) + .Create(); + + var request = new UpdateOrganizationReportV2Request + { + ReportId = reportId, + OrganizationId = orgId, + ContentEncryptionKey = "new-key", + SummaryData = "new-summary", + ApplicationData = "new-app-data", + RequiresNewFileUpload = false + }; + + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(existingReport); + + var result = await sutProvider.Sut.UpdateAsync(request); + + Assert.NotNull(result); + Assert.Equal("new-key", result.ContentEncryptionKey); + Assert.Equal("new-summary", result.SummaryData); + Assert.Equal("new-app-data", result.ApplicationData); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(r => + r.Id == reportId && + r.ContentEncryptionKey == "new-key" && + r.SummaryData == "new-summary" && + r.ApplicationData == "new-app-data")); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_WithMetrics_UpdatesMetricFields( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var existingReport = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, orgId) + .Without(r => r.ReportFile) + .Create(); + + var metrics = new OrganizationReportMetrics + { + ApplicationCount = 100, + ApplicationAtRiskCount = 10, + MemberCount = 50, + MemberAtRiskCount = 5, + PasswordCount = 200, + PasswordAtRiskCount = 20 + }; + + var request = new UpdateOrganizationReportV2Request + { + ReportId = reportId, + OrganizationId = orgId, + ReportMetrics = metrics, + RequiresNewFileUpload = false + }; + + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(existingReport); + + var result = await sutProvider.Sut.UpdateAsync(request); + + Assert.Equal(100, result.ApplicationCount); + Assert.Equal(10, result.ApplicationAtRiskCount); + Assert.Equal(50, result.MemberCount); + Assert.Equal(5, result.MemberAtRiskCount); + Assert.Equal(200, result.PasswordCount); + Assert.Equal(20, result.PasswordAtRiskCount); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_WithRequiresNewFileUpload_CreatesNewReportFile( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var existingReport = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, orgId) + .Without(r => r.ReportFile) + .Create(); + + var request = new UpdateOrganizationReportV2Request + { + ReportId = reportId, + OrganizationId = orgId, + RequiresNewFileUpload = true + }; + + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(existingReport); + + var result = await sutProvider.Sut.UpdateAsync(request); + + var fileData = result.GetReportFile(); + Assert.NotNull(fileData); + Assert.NotNull(fileData.Id); + Assert.Equal(32, fileData.Id.Length); + Assert.Equal("report-data.json", fileData.FileName); + Assert.False(fileData.Validated); + Assert.Equal(0, fileData.Size); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_NullFields_DoesNotOverwriteExisting( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var existingReport = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, orgId) + .With(r => r.ContentEncryptionKey, "original-key") + .With(r => r.SummaryData, "original-summary") + .With(r => r.ApplicationData, "original-app-data") + .Without(r => r.ReportFile) + .Create(); + + var request = new UpdateOrganizationReportV2Request + { + ReportId = reportId, + OrganizationId = orgId, + ContentEncryptionKey = null, + SummaryData = null, + ApplicationData = null, + RequiresNewFileUpload = false + }; + + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(existingReport); + + var result = await sutProvider.Sut.UpdateAsync(request); + + Assert.Equal("original-key", result.ContentEncryptionKey); + Assert.Equal("original-summary", result.SummaryData); + Assert.Equal("original-app-data", result.ApplicationData); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_InvalidOrganization_ThrowsBadRequestException( + SutProvider sutProvider) + { + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.NewGuid(), + OrganizationId = Guid.NewGuid() + }; + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(null as Organization); + + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_ReportNotFound_ThrowsNotFoundException( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.NewGuid(), + OrganizationId = Guid.NewGuid() + }; + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(null as OrganizationReport); + + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_OrgMismatch_ThrowsBadRequestException( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.NewGuid(), + OrganizationId = Guid.NewGuid() + }; + + var existingReport = fixture.Build() + .With(r => r.Id, request.ReportId) + .With(r => r.OrganizationId, Guid.NewGuid()) // different org + .Without(r => r.ReportFile) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + Assert.Equal("Organization report does not belong to the specified organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_EmptyOrganizationId_ThrowsBadRequestException( + SutProvider sutProvider) + { + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.NewGuid(), + OrganizationId = Guid.Empty + }; + + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + Assert.Equal("OrganizationId is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_EmptyReportId_ThrowsBadRequestException( + SutProvider sutProvider) + { + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.Empty, + OrganizationId = Guid.NewGuid() + }; + + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + Assert.Equal("ReportId is required", exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs index 3d9974799d81..68691e5ef120 100644 --- a/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs @@ -28,7 +28,7 @@ private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid o OrganizationId = organizationId, RevisionDate = DateTime.UtcNow.AddDays(-1) }; - report.SetReportFileData(fileData); + report.SetReportFile(fileData); return report; } @@ -54,7 +54,7 @@ public async Task ValidateAsync_ValidFile_SetsValidatedAndUpdatesReport( // Assert Assert.True(result); - var fileData = report.GetReportFileData(); + var fileData = report.GetReportFile(); Assert.NotNull(fileData); Assert.True(fileData!.Validated); Assert.Equal(12345L, fileData.Size); diff --git a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs index f66b9bee02f3..c8f5e4993343 100644 --- a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs @@ -39,7 +39,7 @@ public void FileUploadType_ReturnsAzure() } [Fact] - public async Task GetReportDataUploadUrlAsync_ReturnsValidSasUrl() + public async Task GetReportFileUploadUrlAsync_ReturnsValidSasUrl() { // Arrange var fixture = new Fixture(); @@ -55,7 +55,7 @@ public async Task GetReportDataUploadUrlAsync_ReturnsValidSasUrl() var fileData = CreateFileData(); // Act - var url = await sut.GetReportDataUploadUrlAsync(report, fileData); + var url = await sut.GetReportFileUploadUrlAsync(report, fileData); // Assert Assert.NotNull(url); @@ -125,7 +125,7 @@ public async Task BlobPath_FormatsCorrectly() .Create(); // Act - var url = await sut.GetReportDataUploadUrlAsync(report, fileData); + var url = await sut.GetReportFileUploadUrlAsync(report, fileData); // Assert // Expected path: {orgId}/{MM-dd-yyyy}/{reportId}/{fileId}/report-data.json diff --git a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs index 69d8a2e843e4..2fec83ab927c 100644 --- a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs @@ -41,7 +41,7 @@ public void FileUploadType_ReturnsDirect() } [Fact] - public async Task GetReportDataUploadUrlAsync_ReturnsApiEndpoint() + public async Task GetReportFileUploadUrlAsync_ReturnsApiEndpoint() { // Arrange var fixture = new Fixture(); @@ -59,7 +59,7 @@ public async Task GetReportDataUploadUrlAsync_ReturnsApiEndpoint() var fileData = CreateFileData(); // Act - var url = await sut.GetReportDataUploadUrlAsync(report, fileData); + var url = await sut.GetReportFileUploadUrlAsync(report, fileData); // Assert Assert.Equal($"/reports/organizations/{orgId}/{reportId}/file/report-data", url); From fa9e06e490b8ef352b2b7f2d7048726c565e79bf Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Thu, 5 Mar 2026 11:33:15 -0600 Subject: [PATCH 18/85] PM-31923 remove claude change --- dev/.claude/settings.local.json | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 dev/.claude/settings.local.json diff --git a/dev/.claude/settings.local.json b/dev/.claude/settings.local.json deleted file mode 100644 index ae255b535c61..000000000000 --- a/dev/.claude/settings.local.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(dotnet test:*)", - "Bash(dotnet build:*)" - ] - } -} From cac69fb0b584ad70fe76b70b31a2150c61dd6ae8 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Thu, 5 Mar 2026 11:37:33 -0600 Subject: [PATCH 19/85] PM-31923 fixing feature flag name --- src/Core/Constants.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 7cc92d4a6d34..7cbe4a2254c0 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -268,7 +268,7 @@ public static class FeatureFlagKeys public const string ArchiveVaultItems = "pm-19148-innovation-archive"; /* DIRT Team */ - public const string AccessIntelligenceVersion2 = "pm-31920-whole-report-data-file-storage"; + public const string AccessIntelligenceVersion2 = "pm-31920-access-intelligence-azure-file-storage"; public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging"; public const string EventManagementForHuntress = "event-management-for-huntress"; From a35ff699548d880e12c074da239c162c48a5f842 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:42:59 -0500 Subject: [PATCH 20/85] PM-21720 - RegisterFinishResponseModel - clean up deprecated CaptchaBypassToken (#7098) --- .../Response/Accounts/RegisterFinishResponseModel.cs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Identity/Models/Response/Accounts/RegisterFinishResponseModel.cs b/src/Identity/Models/Response/Accounts/RegisterFinishResponseModel.cs index 564150ab30e0..b4c09a2879fe 100644 --- a/src/Identity/Models/Response/Accounts/RegisterFinishResponseModel.cs +++ b/src/Identity/Models/Response/Accounts/RegisterFinishResponseModel.cs @@ -5,13 +5,5 @@ namespace Bit.Identity.Models.Response.Accounts; public class RegisterFinishResponseModel : ResponseModel { public RegisterFinishResponseModel() - : base("registerFinish") - { - // We are setting this to an empty string so that old mobile clients don't break, as they reqiure a non-null value. - // This will be cleaned up in https://bitwarden.atlassian.net/browse/PM-21720. - CaptchaBypassToken = string.Empty; - } - - public string CaptchaBypassToken { get; set; } - + : base("registerFinish") { } } From 3e518efbd9b0ab6c582b8d92b7352cff12570f62 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:19:57 -0500 Subject: [PATCH 21/85] chore(deps): Add Renovate ownership of MessagePack pinned transitive dependency --- .github/renovate.json5 | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 0796c4dbdfdb..a62871dca4bf 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -98,6 +98,7 @@ "Azure.Storage.Blobs", "Azure.Storage.Queues", "LaunchDarkly.ServerSdk", + "MessagePack", "Microsoft.AspNetCore.Http", "Microsoft.AspNetCore.SignalR.Protocols.MessagePack", "Microsoft.AspNetCore.SignalR.StackExchangeRedis", From b60835f5f656ca6efb42a67d46c95741fa85a93b Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Fri, 6 Mar 2026 07:35:31 -0600 Subject: [PATCH 22/85] PM-31923 fixing path traversal vuln and cleaned up null references --- .../Controllers/OrganizationReportsController.cs | 12 +++++++++++- .../LocalOrganizationReportStorageService.cs | 12 ++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index 067cad0dcc3f..d7b55ac96c36 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -188,6 +188,11 @@ public async Task GetOrganizationReportAsync(Guid organizationId, var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + if (report == null) + { + throw new NotFoundException("Report not found for the specified organization."); + } + if (report.OrganizationId != organizationId) { throw new BadRequestException("Invalid report ID"); @@ -321,7 +326,7 @@ public async Task UploadReportFileAsync(Guid organizationId, Guid reportId, [Fro if (!Request?.ContentType?.Contains("multipart/") ?? true) { - throw new BadRequestException("Invalid contenwt."); + throw new BadRequestException("Invalid content."); } if (string.IsNullOrEmpty(reportFileId)) @@ -330,6 +335,11 @@ public async Task UploadReportFileAsync(Guid organizationId, Guid reportId, [Fro } var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + if (report == null) + { + throw new NotFoundException(); + } + if (report.OrganizationId != organizationId) { throw new BadRequestException("Invalid report ID"); diff --git a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs index 502a66140754..6148278e323a 100644 --- a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs @@ -34,6 +34,7 @@ public async Task UploadReportDataAsync(OrganizationReport report, ReportFile fi OrganizationReport report, ReportFile fileData, long minimum, long maximum) { var path = Path.Combine(_baseDirPath, RelativePath(report, fileData.Id!, fileData.FileName)); + EnsurePathWithinBaseDir(path); if (!File.Exists(path)) { return Task.FromResult((false, -1L)); @@ -59,6 +60,7 @@ private async Task WriteFileAsync(OrganizationReport report, string fileId, stri { InitDir(); var path = Path.Combine(_baseDirPath, RelativePath(report, fileId, fileName)); + EnsurePathWithinBaseDir(path); Directory.CreateDirectory(Path.GetDirectoryName(path)!); using var fs = File.Create(path); stream.Seek(0, SeekOrigin.Begin); @@ -72,6 +74,16 @@ private static string RelativePath(OrganizationReport report, string fileId, str fileId, fileName); } + private void EnsurePathWithinBaseDir(string path) + { + var fullPath = Path.GetFullPath(path); + var fullBaseDir = Path.GetFullPath(_baseDirPath + Path.DirectorySeparatorChar); + if (!fullPath.StartsWith(fullBaseDir, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Path traversal detected."); + } + } + private void InitDir() { if (!Directory.Exists(_baseDirPath)) From de3ea38b8868142796d09ef3e06e8e3ff39a888a Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Fri, 6 Mar 2026 09:47:17 -0600 Subject: [PATCH 23/85] PM-31923 fixing unit test --- .../GetOrganizationReportApplicationDataQuery.cs | 16 ++++++++++++++++ ...rganizationReportApplicationDataQueryTests.cs | 8 ++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs index e1eeba0982c2..f2947b847d9a 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs @@ -1,6 +1,7 @@ using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; using Microsoft.Extensions.Logging; namespace Bit.Core.Dirt.Reports.ReportFeatures; @@ -20,8 +21,23 @@ public GetOrganizationReportApplicationDataQuery( public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) { + if (organizationId == Guid.Empty) + { + throw new BadRequestException("OrganizationId is required."); + } + + if (reportId == Guid.Empty) + { + throw new BadRequestException("ReportId is required."); + } + var applicationDataResponse = await _organizationReportRepo.GetApplicationDataAsync(reportId); + if (applicationDataResponse == null) + { + throw new NotFoundException("Organization report application data not found."); + } + return applicationDataResponse; } } diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs index c9281d52d130..8d399f07c353 100644 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs @@ -42,11 +42,9 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task GetOrganizationReportApplicationDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + Guid reportId, SutProvider sutProvider) { - // Arrange - var reportId = Guid.NewGuid(); - // Act & Assert var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(Guid.Empty, reportId)); @@ -59,11 +57,9 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task GetOrganizationReportApplicationDataAsync_WithEmptyReportId_ShouldThrowBadRequestException( + Guid organizationId, SutProvider sutProvider) { - // Arrange - var organizationId = Guid.NewGuid(); - // Act & Assert var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(organizationId, Guid.Empty)); From d89cd8d7420278453da29a4e6f368f7dc15cedcc Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Fri, 6 Mar 2026 09:56:34 -0600 Subject: [PATCH 24/85] PM-31923 fixing issues found by reviewer --- .../LocalOrganizationReportStorageService.cs | 1 + ...alOrganizationReportStorageServiceTests.cs | 19 +++++-------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs index 6148278e323a..27a44a3070e6 100644 --- a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs @@ -49,6 +49,7 @@ public Task DeleteReportFilesAsync(OrganizationReport report, string reportFileI { var dirPath = Path.Combine(_baseDirPath, report.OrganizationId.ToString(), report.CreationDate.ToString("MM-dd-yyyy"), report.Id.ToString(), reportFileId); + EnsurePathWithinBaseDir(dirPath); if (Directory.Exists(dirPath)) { Directory.Delete(dirPath, true); diff --git a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs index 2fec83ab927c..8609ea65854f 100644 --- a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs @@ -100,10 +100,9 @@ public async Task GetReportDataDownloadUrlAsync_ReturnsBaseUrlWithPath() [Theory] [InlineData("../../etc/malicious")] [InlineData("../../../tmp/evil")] - public async Task UploadReportDataAsync_WithPathTraversalPayload_WritesOutsideBaseDirectory(string maliciousFileId) + public async Task UploadReportDataAsync_WithPathTraversalPayload_ThrowsInvalidOperationException(string maliciousFileId) { - // Arrange - demonstrates the path traversal vulnerability that is mitigated - // by validating reportFileId matches report's file data at the controller/command layer + // Arrange var fixture = new Fixture(); var tempDir = Path.Combine(Path.GetTempPath(), "bitwarden-test-" + Guid.NewGuid()); @@ -132,17 +131,9 @@ public async Task UploadReportDataAsync_WithPathTraversalPayload_WritesOutsideBa try { - // Act - await sut.UploadReportDataAsync(report, maliciousFileData, stream); - - // Assert - the file is written at a path that escapes the intended report directory - var intendedBaseDir = Path.Combine(tempDir, report.OrganizationId.ToString(), - report.CreationDate.ToString("MM-dd-yyyy"), report.Id.ToString()); - var actualFilePath = Path.Combine(intendedBaseDir, maliciousFileId, "report-data.json"); - var resolvedPath = Path.GetFullPath(actualFilePath); - - // This demonstrates the vulnerability: the resolved path escapes the base directory - Assert.False(resolvedPath.StartsWith(Path.GetFullPath(intendedBaseDir))); + // Act & Assert - EnsurePathWithinBaseDir guard rejects the traversal attempt + await Assert.ThrowsAsync( + () => sut.UploadReportDataAsync(report, maliciousFileData, stream)); } finally { From fae1faa2ca9df9ead32ec8bddfcf360038afa658 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Fri, 6 Mar 2026 11:08:56 -0600 Subject: [PATCH 25/85] PM-31923 addressing pr comments --- .../Controllers/OrganizationReportsController.cs | 13 +++++++++++++ .../Response/OrganizationReportResponseModel.cs | 6 ------ src/Core/Dirt/Models/Data/ReportFile.cs | 2 +- .../Requests/UpdateOrganizationReportV2Request.cs | 5 +++++ .../UpdateOrganizationReportV2Command.cs | 12 +++++++++++- .../NoopOrganizationReportStorageService.cs | 2 +- .../OrganizationReportResponseModelTests.cs | 3 --- test/Core.Test/Dirt/Models/Data/ReportFileTests.cs | 2 +- .../LocalOrganizationReportStorageServiceTests.cs | 4 ++-- 9 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index d7b55ac96c36..83b97a3b46a4 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -244,6 +244,19 @@ public async Task UpdateOrganizationReportAsync( request.OrganizationId = organizationId; request.ReportId = reportId; + if (request.RequiresNewFileUpload) + { + if (!request.FileSize.HasValue) + { + throw new BadRequestException("File size is required."); + } + + if (request.FileSize.Value > Constants.FileSize501mb) + { + throw new BadRequestException("Max file size is 500 MB."); + } + } + var report = await _updateReportV2Command.UpdateAsync(request); if (request.RequiresNewFileUpload) diff --git a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs index b457476e65a2..f0f3a90c1102 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs @@ -11,9 +11,6 @@ public class OrganizationReportResponseModel public string? ContentEncryptionKey { get; set; } public string? SummaryData { get; set; } public string? ApplicationData { get; set; } - public int? PasswordCount { get; set; } - public int? PasswordAtRiskCount { get; set; } - public int? MemberCount { get; set; } public ReportFile? ReportFile { get; set; } public string? ReportFileDownloadUrl { get; set; } public DateTime? CreationDate { get; set; } @@ -32,9 +29,6 @@ public OrganizationReportResponseModel(OrganizationReport organizationReport) ContentEncryptionKey = organizationReport.ContentEncryptionKey; SummaryData = organizationReport.SummaryData; ApplicationData = organizationReport.ApplicationData; - PasswordCount = organizationReport.PasswordCount; - PasswordAtRiskCount = organizationReport.PasswordAtRiskCount; - MemberCount = organizationReport.MemberCount; ReportFile = organizationReport.GetReportFile(); CreationDate = organizationReport.CreationDate; RevisionDate = organizationReport.RevisionDate; diff --git a/src/Core/Dirt/Models/Data/ReportFile.cs b/src/Core/Dirt/Models/Data/ReportFile.cs index af05c56918dc..fa0cb11166e9 100644 --- a/src/Core/Dirt/Models/Data/ReportFile.cs +++ b/src/Core/Dirt/Models/Data/ReportFile.cs @@ -26,5 +26,5 @@ public class ReportFile /// /// When true the uploaded file's length has been validated. /// - public bool Validated { get; set; } = true; + public bool Validated { get; set; } = false; } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs index 7ec4f76a2ffe..5a44f4684e05 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs @@ -10,4 +10,9 @@ public class UpdateOrganizationReportV2Request public string? ApplicationData { get; set; } public OrganizationReportMetrics? ReportMetrics { get; set; } public bool RequiresNewFileUpload { get; set; } + + /// + /// Estimated size of the report file in bytes. Required when RequiresNewFileUpload is true. + /// + public long? FileSize { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs index fb5ff4a0daac..468b0a6f7ec6 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs @@ -2,6 +2,7 @@ using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Services; using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -14,15 +15,18 @@ public class UpdateOrganizationReportV2Command : IUpdateOrganizationReportV2Comm { private readonly IOrganizationRepository _organizationRepo; private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IOrganizationReportStorageService _storageService; private readonly ILogger _logger; public UpdateOrganizationReportV2Command( IOrganizationRepository organizationRepository, IOrganizationReportRepository organizationReportRepository, + IOrganizationReportStorageService storageService, ILogger logger) { _organizationRepo = organizationRepository; _organizationReportRepo = organizationReportRepository; + _storageService = storageService; _logger = logger; } @@ -90,12 +94,18 @@ public async Task UpdateAsync(UpdateOrganizationReportV2Requ if (request.RequiresNewFileUpload) { + var oldFileData = existingReport.GetReportFile(); + if (oldFileData?.Id != null) + { + await _storageService.DeleteReportFilesAsync(existingReport, oldFileData.Id); + } + var fileData = new ReportFile { Id = CoreHelpers.SecureRandomString(32, upper: false, special: false), FileName = "report-data.json", Validated = false, - Size = 0 + Size = request.FileSize ?? 0 }; existingReport.SetReportFile(fileData); } diff --git a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs index c9260914008d..fb56ced538cf 100644 --- a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs @@ -14,7 +14,7 @@ public class NoopOrganizationReportStorageService : IOrganizationReportStorageSe public Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream) => Task.CompletedTask; - public Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, ReportFile fileData, long minimum, long maximum) => Task.FromResult((true, 0L)); + public Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, ReportFile fileData, long minimum, long maximum) => Task.FromResult((true, fileData.Size)); public Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId) => Task.CompletedTask; } diff --git a/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs index 00062a799d09..2d67407a050d 100644 --- a/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs +++ b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs @@ -19,9 +19,6 @@ public void Constructor_MapsPropertiesFromEntity(OrganizationReport report) Assert.Equal(report.ContentEncryptionKey, model.ContentEncryptionKey); Assert.Equal(report.SummaryData, model.SummaryData); Assert.Equal(report.ApplicationData, model.ApplicationData); - Assert.Equal(report.PasswordCount, model.PasswordCount); - Assert.Equal(report.PasswordAtRiskCount, model.PasswordAtRiskCount); - Assert.Equal(report.MemberCount, model.MemberCount); Assert.Equal(report.CreationDate, model.CreationDate); Assert.Equal(report.RevisionDate, model.RevisionDate); } diff --git a/test/Core.Test/Dirt/Models/Data/ReportFileTests.cs b/test/Core.Test/Dirt/Models/Data/ReportFileTests.cs index db0a1865df88..eeb71955584c 100644 --- a/test/Core.Test/Dirt/Models/Data/ReportFileTests.cs +++ b/test/Core.Test/Dirt/Models/Data/ReportFileTests.cs @@ -14,7 +14,7 @@ public void DefaultValues_AreCorrect() Assert.Null(data.Id); Assert.Equal(string.Empty, data.FileName); Assert.Equal(0, data.Size); - Assert.True(data.Validated); + Assert.False(data.Validated); } [Fact] diff --git a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs index 8609ea65854f..81a36e4055bf 100644 --- a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs @@ -98,8 +98,8 @@ public async Task GetReportDataDownloadUrlAsync_ReturnsBaseUrlWithPath() } [Theory] - [InlineData("../../etc/malicious")] - [InlineData("../../../tmp/evil")] + [InlineData("../../../../etc/malicious")] + [InlineData("../../../../../tmp/evil")] public async Task UploadReportDataAsync_WithPathTraversalPayload_ThrowsInvalidOperationException(string maliciousFileId) { // Arrange From 437f212a7a27cf085d90f4cc5a8af7b3529810a7 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:35:53 -0700 Subject: [PATCH 26/85] [PM-33219] Resolve silent auth removal on Sends (#7160) * remove null assignment to auth props and update tests * update PutRemoveAuth comment for clarity and assign null to empty email list allowing future client side changes to remove ALL emails * update test to match email removal expectation * implement expected behavior and update tests --------- Co-authored-by: Alex Dragovich <46065570+itsadrago@users.noreply.github.com> --- src/Api/Tools/Controllers/SendsController.cs | 2 - .../Tools/Models/Request/SendRequestModel.cs | 7 +- src/Api/Tools/Utilities/InferAuthType.cs | 1 - .../Tools/Controllers/SendsControllerTests.cs | 105 ++++-------------- .../Models/Request/SendRequestModelTests.cs | 63 +++++++++++ 5 files changed, 85 insertions(+), 93 deletions(-) diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index 5b7143efc3eb..46938ddd9a09 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -452,8 +452,6 @@ public async Task PutRemoveAuth(string id) throw new NotFoundException(); } - // This endpoint exists because PUT preserves existing Password/Emails when not provided. - // This allows clients to update other fields without re-submitting sensitive auth data. send.Password = null; send.Emails = null; send.AuthType = AuthType.None; diff --git a/src/Api/Tools/Models/Request/SendRequestModel.cs b/src/Api/Tools/Models/Request/SendRequestModel.cs index cd76f2673265..da99624ed871 100644 --- a/src/Api/Tools/Models/Request/SendRequestModel.cs +++ b/src/Api/Tools/Models/Request/SendRequestModel.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; +using Bit.Api.Tools.Utilities; using Bit.Core.Exceptions; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; @@ -253,21 +254,19 @@ private Send ToSendBase(Send existingSend, ISendAuthorizationService authorizati var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries); existingSend.Emails = string.Join(",", emails); existingSend.Password = null; - existingSend.AuthType = Core.Tools.Enums.AuthType.Email; } else if (!string.IsNullOrWhiteSpace(Password)) { existingSend.Password = authorizationService.HashPassword(Password); existingSend.Emails = null; - existingSend.AuthType = Core.Tools.Enums.AuthType.Password; } - else + else if (existingSend.AuthType == Core.Tools.Enums.AuthType.Email) { existingSend.Emails = null; existingSend.Password = null; - existingSend.AuthType = Core.Tools.Enums.AuthType.None; } + existingSend.AuthType = SendUtilities.InferAuthType(existingSend); existingSend.Disabled = Disabled.GetValueOrDefault(); existingSend.HideEmail = HideEmail.GetValueOrDefault(); diff --git a/src/Api/Tools/Utilities/InferAuthType.cs b/src/Api/Tools/Utilities/InferAuthType.cs index 785fde1ec9f2..7b72c32571c1 100644 --- a/src/Api/Tools/Utilities/InferAuthType.cs +++ b/src/Api/Tools/Utilities/InferAuthType.cs @@ -20,4 +20,3 @@ public static AuthType InferAuthType(Send send) return AuthType.None; } } - diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs index a5fe7d4e9763..9ef46b136ce1 100644 --- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs @@ -114,20 +114,6 @@ public async Task Post_DeletionDateIsMoreThan31DaysFromNow_ThrowsBadRequest() Assert.Equal(expected, exception.Message); } - [Fact] - public async Task PostFile_DeletionDateIsMoreThan31DaysFromNow_ThrowsBadRequest() - { - var now = DateTime.UtcNow; - var expected = "You cannot have a Send with a deletion date that far " + - "into the future. Adjust the Deletion Date to a value less than 31 days from now " + - "and try again."; - var request = - new SendRequestModel() { Type = SendType.File, FileLength = 1024L, DeletionDate = now.AddDays(32) }; - - var exception = await Assert.ThrowsAsync(() => _sut.PostFile(request)); - Assert.Equal(expected, exception.Message); - } - [Theory, AutoData] public async Task Get_WithValidId_ReturnsSendResponseModel(Guid sendId, Send send) { @@ -190,6 +176,7 @@ public async Task GetAllOwned_WhenNoSends_ReturnsEmptyListResponseModel() public async Task Post_WithPassword_InfersAuthTypePassword(Guid userId) { _userService.GetProperUserId(Arg.Any()).Returns(userId); + _sendAuthorizationService.HashPassword(Arg.Any()).Returns("hashed_password"); var request = new SendRequestModel { Type = SendType.Text, @@ -547,6 +534,7 @@ public async Task Delete_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid public async Task PostFile_WithPassword_InfersAuthTypePassword(Guid userId) { _userService.GetProperUserId(Arg.Any()).Returns(userId); + _sendAuthorizationService.HashPassword(Arg.Any()).Returns("hashed_password"); _nonAnonymousSendCommand.SaveFileSendAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns("https://example.com/upload") .AndDoes(callInfo => @@ -697,6 +685,7 @@ await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => public async Task Put_ChangingFromEmailToPassword_UpdatesAuthTypeToPassword(Guid userId, Guid sendId) { _userService.GetProperUserId(Arg.Any()).Returns(userId); + _sendAuthorizationService.HashPassword(Arg.Any()).Returns("hashed_password"); var existingSend = new Send { Id = sendId, @@ -729,7 +718,7 @@ await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => } [Theory, AutoData] - public async Task Put_WithoutPasswordOrEmails_ClearsExistingPassword(Guid userId, Guid sendId) + public async Task Put_WithoutPasswordOrEmails_PreservesNoneAuthType(Guid userId, Guid sendId) { _userService.GetProperUserId(Arg.Any()).Returns(userId); var existingSend = new Send @@ -738,8 +727,9 @@ public async Task Put_WithoutPasswordOrEmails_ClearsExistingPassword(Guid userId UserId = userId, Type = SendType.Text, Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)), - Password = "hashed-password", - AuthType = AuthType.Password + Password = null, + Emails = null, + AuthType = AuthType.None }; _sendRepository.GetByIdAsync(sendId).Returns(existingSend); @@ -763,7 +753,7 @@ await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => } [Theory, AutoData] - public async Task Put_WithoutPasswordOrEmails_ClearsExistingEmails(Guid userId, Guid sendId) + public async Task Put_WithExistingPasswordAuth_WhenNoAuthInRequest_PreservesPasswordAuth(Guid userId, Guid sendId) { _userService.GetProperUserId(Arg.Any()).Returns(userId); var existingSend = new Send @@ -772,8 +762,9 @@ public async Task Put_WithoutPasswordOrEmails_ClearsExistingEmails(Guid userId, UserId = userId, Type = SendType.Text, Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)), - Emails = "test@example.com", - AuthType = AuthType.Email + Password = "hashed-password", + Emails = null, + AuthType = AuthType.Password }; _sendRepository.GetByIdAsync(sendId).Returns(existingSend); @@ -791,24 +782,25 @@ public async Task Put_WithoutPasswordOrEmails_ClearsExistingEmails(Guid userId, Assert.Equal(sendId, result.Id); await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => s.Id == sendId && - s.AuthType == AuthType.None && - s.Password == null && + s.AuthType == AuthType.Password && + s.Password != null && s.Emails == null)); } [Theory, AutoData] - public async Task Put_WithoutPasswordOrEmails_PreservesNoneAuthType(Guid userId, Guid sendId) + public async Task Put_WithExistingEmailAuth_WhenNoAuthInRequest_ClearsEmailAuth(Guid userId, Guid sendId) { _userService.GetProperUserId(Arg.Any()).Returns(userId); + _hasPremiumAccessQuery.HasPremiumAccessAsync(userId).Returns(true); var existingSend = new Send { Id = sendId, UserId = userId, Type = SendType.Text, Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)), + Emails = "old@example.com", Password = null, - Emails = null, - AuthType = AuthType.None + AuthType = AuthType.Email }; _sendRepository.GetByIdAsync(sendId).Returns(existingSend); @@ -827,8 +819,8 @@ public async Task Put_WithoutPasswordOrEmails_PreservesNoneAuthType(Guid userId, await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => s.Id == sendId && s.AuthType == AuthType.None && - s.Password == null && - s.Emails == null)); + s.Emails == null && + s.Password == null)); } #region Authenticated Access Endpoints @@ -1303,65 +1295,6 @@ await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => s.AuthType == AuthType.None)); } - [Theory, AutoData] - public async Task PutRemoveAuth_WithSendAlreadyHavingNoAuth_StillSucceeds(Guid userId, Guid sendId) - { - _userService.GetProperUserId(Arg.Any()).Returns(userId); - var existingSend = new Send - { - Id = sendId, - UserId = userId, - Type = SendType.Text, - Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), - Password = null, - Emails = null, - AuthType = AuthType.None - }; - _sendRepository.GetByIdAsync(sendId).Returns(existingSend); - - var result = await _sut.PutRemoveAuth(sendId.ToString()); - - Assert.NotNull(result); - Assert.Equal(sendId, result.Id); - Assert.Equal(AuthType.None, result.AuthType); - Assert.Null(result.Password); - Assert.Null(result.Emails); - await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => - s.Id == sendId && - s.Password == null && - s.Emails == null && - s.AuthType == AuthType.None)); - } - - [Theory, AutoData] - public async Task PutRemoveAuth_WithFileSend_RemovesAuthAndPreservesFileData(Guid userId, Guid sendId) - { - _userService.GetProperUserId(Arg.Any()).Returns(userId); - var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = "file-123", Size = 2048 }; - var existingSend = new Send - { - Id = sendId, - UserId = userId, - Type = SendType.File, - Data = JsonSerializer.Serialize(fileData), - Password = "hashed-password", - Emails = null, - AuthType = AuthType.Password - }; - _sendRepository.GetByIdAsync(sendId).Returns(existingSend); - - var result = await _sut.PutRemoveAuth(sendId.ToString()); - - Assert.NotNull(result); - Assert.Equal(sendId, result.Id); - Assert.Equal(AuthType.None, result.AuthType); - Assert.Equal(SendType.File, result.Type); - Assert.NotNull(result.File); - Assert.Equal("file-123", result.File.Id); - Assert.Null(result.Password); - Assert.Null(result.Emails); - } - [Theory, AutoData] public async Task PutRemoveAuth_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId) { diff --git a/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs b/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs index 804966701122..f03cebacaf1e 100644 --- a/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs +++ b/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs @@ -2,6 +2,7 @@ using Bit.Api.Tools.Models; using Bit.Api.Tools.Models.Request; using Bit.Core.Exceptions; +using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Services; using Bit.Test.Common.Helpers; @@ -115,4 +116,66 @@ public void ValidateEdit_ValidDates_Success() Assert.Null(ex); } + + [Fact] + public void UpdateSend_WithExistingPasswordAuth_WhenNoAuthInRequest_PreservesPasswordAuth() + { + var deletionDate = DateTime.UtcNow.AddDays(5); + var sendRequest = new SendRequestModel + { + DeletionDate = deletionDate, + Disabled = false, + Key = "encrypted_key", + Name = "encrypted_name", + Text = new SendTextModel { Hidden = false, Text = "encrypted_text" }, + Type = SendType.Text, + }; + + var existingSend = new Send + { + Type = SendType.Text, + Password = "existing_hashed_password", + AuthType = AuthType.Password, + Emails = null, + }; + + var sendAuthorizationService = Substitute.For(); + + var updatedSend = sendRequest.UpdateSend(existingSend, sendAuthorizationService); + + Assert.Equal(AuthType.Password, updatedSend.AuthType); + Assert.Equal("existing_hashed_password", updatedSend.Password); + Assert.Null(updatedSend.Emails); + } + + [Fact] + public void UpdateSend_WithExistingEmailAuth_WhenNoAuthInRequest_ClearsEmailsAndSetsAuthTypeNone() + { + var deletionDate = DateTime.UtcNow.AddDays(5); + var sendRequest = new SendRequestModel + { + DeletionDate = deletionDate, + Disabled = false, + Key = "encrypted_key", + Name = "encrypted_name", + Text = new SendTextModel { Hidden = false, Text = "encrypted_text" }, + Type = SendType.Text, + }; + + var existingSend = new Send + { + Type = SendType.Text, + Emails = "old@example.com", + AuthType = AuthType.Email, + Password = null, + }; + + var sendAuthorizationService = Substitute.For(); + + var updatedSend = sendRequest.UpdateSend(existingSend, sendAuthorizationService); + + Assert.Equal(AuthType.None, updatedSend.AuthType); + Assert.Null(updatedSend.Emails); + Assert.Null(updatedSend.Password); + } } From 36476bb56e72db8952bcb07777608fd2d89813ad Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Fri, 6 Mar 2026 16:13:51 -0600 Subject: [PATCH 27/85] PM-31923 fixing issues based on review --- dev/.claude/settings.local.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 dev/.claude/settings.local.json diff --git a/dev/.claude/settings.local.json b/dev/.claude/settings.local.json new file mode 100644 index 000000000000..d56caaa428fd --- /dev/null +++ b/dev/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(gh api:*)" + ] + } +} From 309f347a333657771856a9d9164af5ffd7082ca4 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Fri, 6 Mar 2026 16:17:45 -0600 Subject: [PATCH 28/85] PM-31923 removing settings.json --- dev/.claude/settings.local.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 dev/.claude/settings.local.json diff --git a/dev/.claude/settings.local.json b/dev/.claude/settings.local.json deleted file mode 100644 index d56caaa428fd..000000000000 --- a/dev/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(gh api:*)" - ] - } -} From 36c16967ba11da184bebacf21fedf63b6a08b07f Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 9 Mar 2026 10:51:54 +0000 Subject: [PATCH 29/85] Bumped version to 2026.3.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index adf984f7d195..de4a2ef3f309 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2026.2.1 + 2026.3.0 Bit.$(MSBuildProjectName) enable From 0c3713823e2dbf09f46aea30c64025d765a26f95 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Mon, 9 Mar 2026 09:50:53 -0400 Subject: [PATCH 30/85] [PM-33091] Add optional Targeting Rules data resource configuration (#7137) * add fillAssistRules to environment URIs in config * add tests * do not include json file specification in path * fix warnings --- .../Models/Response/ConfigResponseModel.cs | 7 +++- src/Api/appsettings.Development.json | 1 + src/Core/Settings/GlobalSettings.cs | 14 ++++++- src/Core/Settings/IBaseServiceUriSettings.cs | 1 + .../Controllers/ConfigControllerTests.cs | 23 +++++++++++ .../EnvironmentConfigResponseModelTests.cs | 40 +++++++++++++++++++ 6 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 test/Api.Test/Models/Response/EnvironmentConfigResponseModelTests.cs diff --git a/src/Api/Models/Response/ConfigResponseModel.cs b/src/Api/Models/Response/ConfigResponseModel.cs index e6ac35bb398b..aee59fce03c3 100644 --- a/src/Api/Models/Response/ConfigResponseModel.cs +++ b/src/Api/Models/Response/ConfigResponseModel.cs @@ -1,6 +1,8 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using System.Text.Json.Serialization; + using Bit.Core; using Bit.Core.Enums; using Bit.Core.Models.Api; @@ -44,7 +46,8 @@ IGlobalSettings globalSettings Api = globalSettings.BaseServiceUri.Api, Identity = globalSettings.BaseServiceUri.Identity, Notifications = globalSettings.BaseServiceUri.Notifications, - Sso = globalSettings.BaseServiceUri.Sso + Sso = globalSettings.BaseServiceUri.Sso, + FillAssistRules = globalSettings.BaseServiceUri.FillAssistRules }; FeatureStates = featureService.GetAll(); var webPushEnabled = FeatureStates.TryGetValue(FeatureFlagKeys.WebPush, out var webPushEnabledValue) ? (bool)webPushEnabledValue : false; @@ -71,6 +74,8 @@ public class EnvironmentConfigResponseModel public string Identity { get; set; } public string Notifications { get; set; } public string Sso { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string FillAssistRules { get; set; } } public class PushSettings diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json index deb0a35d842b..dd51d76e7aea 100644 --- a/src/Api/appsettings.Development.json +++ b/src/Api/appsettings.Development.json @@ -7,6 +7,7 @@ "admin": "http://localhost:62911", "notifications": "http://localhost:61840", "sso": "http://localhost:51822", + "fillAssistRules": "http://localhost:1495", "internalNotifications": "http://localhost:61840", "internalAdmin": "http://localhost:62911", "internalIdentity": "http://localhost:33656", diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index b9ba879b0b2f..1eb7d28b9cf7 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -1,6 +1,8 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using System.Globalization; + using Bit.Core.Auth.Settings; namespace Bit.Core.Settings; @@ -105,7 +107,7 @@ public string BuildExternalUri(string explicitValue, string name) { return null; } - return string.Format("{0}/{1}", BaseServiceUri.Vault, name); + return string.Format(CultureInfo.InvariantCulture, "{0}/{1}", BaseServiceUri.Vault, name); } public string BuildInternalUri(string explicitValue, string name) @@ -118,7 +120,7 @@ public string BuildInternalUri(string explicitValue, string name) { return null; } - return string.Format("http://{0}:5000", name); + return string.Format(CultureInfo.InvariantCulture, "http://{0}:5000", name); } public string BuildDirectory(string explicitValue, string appendedPath) @@ -144,6 +146,7 @@ public class BaseServiceUriSettings : IBaseServiceUriSettings private string _notifications; private string _sso; private string _scim; + private string _fillAssistRules; private string _internalApi; private string _internalIdentity; private string _internalAdmin; @@ -194,6 +197,13 @@ public string Scim get => _globalSettings.BuildExternalUri(_scim, "scim"); set => _scim = value; } + // Simple passthrough — not derived from the Vault URL because + // this points to an external resource, not a Bitwarden service. + public string FillAssistRules + { + get => _fillAssistRules; + set => _fillAssistRules = value; + } public string InternalNotifications { diff --git a/src/Core/Settings/IBaseServiceUriSettings.cs b/src/Core/Settings/IBaseServiceUriSettings.cs index 2a1d165ac1e3..877356e9d662 100644 --- a/src/Core/Settings/IBaseServiceUriSettings.cs +++ b/src/Core/Settings/IBaseServiceUriSettings.cs @@ -13,6 +13,7 @@ public interface IBaseServiceUriSettings public string Notifications { get; set; } public string Sso { get; set; } public string Scim { get; set; } + public string FillAssistRules { get; set; } public string InternalNotifications { get; set; } public string InternalAdmin { get; set; } public string InternalIdentity { get; set; } diff --git a/test/Api.Test/Controllers/ConfigControllerTests.cs b/test/Api.Test/Controllers/ConfigControllerTests.cs index 4df35209474c..941ad70f3320 100644 --- a/test/Api.Test/Controllers/ConfigControllerTests.cs +++ b/test/Api.Test/Controllers/ConfigControllerTests.cs @@ -17,6 +17,7 @@ public ConfigControllerTests() { _globalSettings = new GlobalSettings(); _featureService = Substitute.For(); + _featureService.GetAll().Returns(new Dictionary()); _sut = new ConfigController( _globalSettings, @@ -40,4 +41,26 @@ public void GetConfigs_WithFeatureStates(Dictionary featureState Assert.NotNull(response.FeatureStates); Assert.Equal(featureStates, response.FeatureStates); } + + [Fact] + public void GetConfigs_FillAssistRulesNotConfigured_ReturnsNullEnvironmentValue() + { + // BaseServiceUriSettings.FillAssistRules defaults to null when not explicitly set + var response = _sut.GetConfigs(); + + Assert.NotNull(response.Environment); + Assert.Null(response.Environment.FillAssistRules); + } + + [Fact] + public void GetConfigs_FillAssistRulesConfigured_ReturnsConfiguredValue() + { + var expectedUri = "https://example.com/custom-rules.json"; + _globalSettings.BaseServiceUri.FillAssistRules = expectedUri; + + var response = _sut.GetConfigs(); + + Assert.NotNull(response.Environment); + Assert.Equal(expectedUri, response.Environment.FillAssistRules); + } } diff --git a/test/Api.Test/Models/Response/EnvironmentConfigResponseModelTests.cs b/test/Api.Test/Models/Response/EnvironmentConfigResponseModelTests.cs new file mode 100644 index 000000000000..1c74b97a2e92 --- /dev/null +++ b/test/Api.Test/Models/Response/EnvironmentConfigResponseModelTests.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using Bit.Api.Models.Response; +using Xunit; + +namespace Bit.Api.Test.Models.Response; + +public class EnvironmentConfigResponseModelTests +{ + [Fact] + public void Serialize_FillAssistRulesNull_OmitsPropertyFromJson() + { + var model = new EnvironmentConfigResponseModel + { + CloudRegion = "US", + Vault = "https://vault.bitwarden.com", + FillAssistRules = null + }; + + var json = JsonSerializer.Serialize(model); + + Assert.DoesNotContain("FillAssistRules", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Serialize_FillAssistRulesSet_IncludesPropertyInJson() + { + var expectedUri = "https://example.com/rules.json"; + var model = new EnvironmentConfigResponseModel + { + CloudRegion = "US", + Vault = "https://vault.bitwarden.com", + FillAssistRules = expectedUri + }; + + var json = JsonSerializer.Serialize(model); + + Assert.Contains("FillAssistRules", json); + Assert.Contains(expectedUri, json); + } +} From c29927fbaef7cbc7137ccfa99a3365c9f1824e35 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Mon, 9 Mar 2026 10:29:35 -0400 Subject: [PATCH 31/85] fix(feature-flag): [PM-27085] Account Register Uses New Data Types - Removed unnneded feature flag. (#7127) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 08d7f6c29254..9f360127b0fe 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -166,7 +166,6 @@ public static class FeatureFlagKeys public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin"; public const string SafariAccountSwitching = "pm-5594-safari-account-switching"; public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password"; - public const string PM27044_UpdateRegistrationApis = "pm-27044-update-registration-apis"; public const string ChangeEmailNewAuthenticationApis = "pm-30811-change-email-new-authentication-apis"; public const string PM31088_MasterPasswordServiceEmitSalt = "pm-31088-master-password-service-emit-salt"; From 1f5c35912827f14fe31bef1227fdd4048d74c1e1 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Mon, 9 Mar 2026 09:41:21 -0500 Subject: [PATCH 32/85] PM-31923 fixing unit tests --- .../AzureOrganizationReportStorageService.cs | 15 +++++- ...reOrganizationReportStorageServiceTests.cs | 49 +++++++++++-------- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs index faa8c85c93c8..13823cf25765 100644 --- a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs @@ -30,6 +30,19 @@ public AzureOrganizationReportStorageService( _logger = logger; } + /// + /// Constructor for unit testing that accepts a pre-initialized container client, + /// bypassing the network call to Azure Storage. + /// + internal AzureOrganizationReportStorageService( + BlobContainerClient containerClient, + ILogger logger) + { + _blobServiceClient = null!; + _containerClient = containerClient; + _logger = logger; + } + public async Task GetReportFileUploadUrlAsync(OrganizationReport report, ReportFile fileData) { await InitAsync(); @@ -104,7 +117,7 @@ public async Task DeleteReportFilesAsync(OrganizationReport report, string repor } } - private static string BlobPath(OrganizationReport report, string fileId, string fileName) + internal static string BlobPath(OrganizationReport report, string fileId, string fileName) { var date = report.CreationDate.ToString("MM-dd-yyyy"); return $"{report.OrganizationId}/{date}/{report.Id}/{fileId}/{fileName}"; diff --git a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs index c8f5e4993343..05cad82130a3 100644 --- a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs @@ -1,24 +1,26 @@ using AutoFixture; +using Azure.Storage.Blobs; using Bit.Core.Dirt.Entities; using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.Services; using Bit.Core.Enums; -using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; namespace Bit.Core.Test.Dirt.Reports.Services; -[SutProviderCustomize] public class AzureOrganizationReportStorageServiceTests { + private const string DevConnectionString = "UseDevelopmentStorage=true"; + private static AzureOrganizationReportStorageService CreateSut() { - var globalSettings = new Core.Settings.GlobalSettings(); - globalSettings.OrganizationReport.ConnectionString = "UseDevelopmentStorage=true"; + var blobServiceClient = new BlobServiceClient(DevConnectionString); + var containerClient = blobServiceClient.GetBlobContainerClient( + AzureOrganizationReportStorageService.ContainerName); var logger = Substitute.For>(); - return new AzureOrganizationReportStorageService(globalSettings, logger); + return new AzureOrganizationReportStorageService(containerClient, logger); } private static ReportFile CreateFileData(string fileId = "test-file-id-123") @@ -34,7 +36,6 @@ private static ReportFile CreateFileData(string fileId = "test-file-id-123") [Fact] public void FileUploadType_ReturnsAzure() { - // Arrange & Act & Assert Assert.Equal(FileUploadType.Azure, CreateSut().FileUploadType); } @@ -61,9 +62,15 @@ public async Task GetReportFileUploadUrlAsync_ReturnsValidSasUrl() Assert.NotNull(url); Assert.NotEmpty(url); Assert.Contains("report-data.json", url); - Assert.Contains("sig=", url); // SAS signature - Assert.Contains("sp=", url); // Permissions - Assert.Contains("se=", url); // Expiry + Assert.Contains("sig=", url); + Assert.Contains("se=", url); + // Upload URL should have create and write permissions + var uri = new Uri(url); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + var permissions = query["sp"]; + Assert.NotNull(permissions); + Assert.Contains("c", permissions); // Create + Assert.Contains("w", permissions); // Write } [Fact] @@ -89,8 +96,14 @@ public async Task GetReportDataDownloadUrlAsync_ReturnsValidSasUrl() Assert.NotNull(url); Assert.NotEmpty(url); Assert.Contains("report-data.json", url); - Assert.Contains("sig=", url); // SAS signature - Assert.Contains("sp=", url); // Permissions (should be read-only) + Assert.Contains("sig=", url); + // Download URL should have read-only permission + var uri = new Uri(url); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + var permissions = query["sp"]; + Assert.NotNull(permissions); + Assert.Contains("r", permissions); // Read + Assert.DoesNotContain("w", permissions); // No write } [Theory] @@ -98,24 +111,19 @@ public async Task GetReportDataDownloadUrlAsync_ReturnsValidSasUrl() [InlineData("abc/01-01-2026/def/ghi/report-data.json", "def")] public void ReportIdFromBlobName_ExtractsReportId(string blobName, string expectedReportId) { - // Act var result = AzureOrganizationReportStorageService.ReportIdFromBlobName(blobName); - - // Assert Assert.Equal(expectedReportId, result); } [Fact] - public async Task BlobPath_FormatsCorrectly() + public void BlobPath_FormatsCorrectly() { // Arrange var fixture = new Fixture(); - var sut = CreateSut(); var orgId = Guid.Parse("11111111-1111-1111-1111-111111111111"); var reportId = Guid.Parse("22222222-2222-2222-2222-222222222222"); var creationDate = new DateTime(2026, 2, 17); - var fileData = CreateFileData("abc123xyz"); var report = fixture.Build() .With(r => r.OrganizationId, orgId) @@ -125,11 +133,10 @@ public async Task BlobPath_FormatsCorrectly() .Create(); // Act - var url = await sut.GetReportFileUploadUrlAsync(report, fileData); + var path = AzureOrganizationReportStorageService.BlobPath(report, "abc123xyz", "report-data.json"); // Assert - // Expected path: {orgId}/{MM-dd-yyyy}/{reportId}/{fileId}/report-data.json - var expectedPath = $"{orgId}/02-17-2026/{reportId}/{fileData.Id}/report-data.json"; - Assert.Contains(expectedPath, url); + var expectedPath = $"{orgId}/02-17-2026/{reportId}/abc123xyz/report-data.json"; + Assert.Equal(expectedPath, path); } } From b16061c9d5968772c3c4a368e5583e776dbb2866 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:05:31 -0400 Subject: [PATCH 33/85] Auth/PM-32416 - Add MultiClientPasswordManagement feature flag (#7169) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 9f360127b0fe..3077a49ab310 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -168,6 +168,7 @@ public static class FeatureFlagKeys public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password"; public const string ChangeEmailNewAuthenticationApis = "pm-30811-change-email-new-authentication-apis"; public const string PM31088_MasterPasswordServiceEmitSalt = "pm-31088-master-password-service-emit-salt"; + public const string PM32413_MultiClientPasswordManagement = "pm-32413-multi-client-password-management"; /* Autofill Team */ public const string SSHAgentV2 = "ssh-agent-v2"; From e6e178562e6d435ff2eeeb2aa35e3fa3f7d1fb8c Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:36:24 -0400 Subject: [PATCH 34/85] chore(flags): [PM-32554] Remove pm-24579-prevent-sso-on-existing-non-compliant-users feature flag * Remove flag. * Removed unneccessary dependency * Remove unnecessary dependency. * Removed additional temporary test fixtures. --------- Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> --- .../src/Sso/Controllers/AccountController.cs | 103 ++--- .../Controllers/AccountControllerTest.cs | 361 +----------------- .../Controllers/AccountControllerTests.cs | 130 +------ src/Core/Constants.cs | 1 - 4 files changed, 36 insertions(+), 559 deletions(-) diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 3d998b6a75ab..5ce2f1dc6464 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -54,7 +54,6 @@ public class AccountController : Controller private readonly IDataProtectorTokenFactory _dataProtector; private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IRegisterUserCommand _registerUserCommand; - private readonly IFeatureService _featureService; public AccountController( IAuthenticationSchemeProvider schemeProvider, @@ -75,8 +74,7 @@ public AccountController( Core.Services.IEventService eventService, IDataProtectorTokenFactory dataProtector, IOrganizationDomainRepository organizationDomainRepository, - IRegisterUserCommand registerUserCommand, - IFeatureService featureService) + IRegisterUserCommand registerUserCommand) { _schemeProvider = schemeProvider; _clientStore = clientStore; @@ -97,7 +95,6 @@ public AccountController( _dataProtector = dataProtector; _organizationDomainRepository = organizationDomainRepository; _registerUserCommand = registerUserCommand; - _featureService = featureService; } [HttpGet] @@ -266,27 +263,13 @@ private void ValidateSchemeAgainstSsoToken(string scheme, string ssoToken) [HttpGet] public async Task ExternalCallback() { - // Feature flag (PM-24579): Prevent SSO on existing non-compliant users. - var preventOrgUserLoginIfStatusInvalid = - _featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers); - // Read external identity from the temporary cookie var result = await HttpContext.AuthenticateAsync( AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); - if (preventOrgUserLoginIfStatusInvalid) - { - if (!result.Succeeded) - { - throw new Exception(_i18nService.T("ExternalAuthenticationError")); - } - } - else + if (!result.Succeeded) { - if (result?.Succeeded != true) - { - throw new Exception(_i18nService.T("ExternalAuthenticationError")); - } + throw new Exception(_i18nService.T("ExternalAuthenticationError")); } // See if the user has logged in with this SSO provider before and has already been provisioned. @@ -318,70 +301,34 @@ await CreateUserAndOrgUserConditionallyAsync( #nullable restore possibleSsoLinkedUser = resolvedUser; - - if (preventOrgUserLoginIfStatusInvalid) - { - organization = foundOrganization; - orgUser = foundOrCreatedOrgUser; - } + organization = foundOrganization; + orgUser = foundOrCreatedOrgUser; } - if (preventOrgUserLoginIfStatusInvalid) - { - User resolvedSsoLinkedUser = possibleSsoLinkedUser - ?? throw new Exception(_i18nService.T("UserShouldBeFound")); - - await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, resolvedSsoLinkedUser); + User resolvedSsoLinkedUser = possibleSsoLinkedUser + ?? throw new Exception(_i18nService.T("UserShouldBeFound")); - // This allows us to collect any additional claims or properties - // for the specific protocols used and store them in the local auth cookie. - // this is typically used to store data needed for signout from those protocols. - var additionalLocalClaims = new List(); - var localSignInProps = new AuthenticationProperties - { - IsPersistent = true, - ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1) - }; - ProcessLoginCallback(result, additionalLocalClaims, localSignInProps); + await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, resolvedSsoLinkedUser); - // Issue authentication cookie for user - await HttpContext.SignInAsync( - new IdentityServerUser(resolvedSsoLinkedUser.Id.ToString()) - { - DisplayName = resolvedSsoLinkedUser.Email, - IdentityProvider = provider, - AdditionalClaims = additionalLocalClaims.ToArray() - }, localSignInProps); - } - else + // This allows us to collect any additional claims or properties + // for the specific protocols used and store them in the local auth cookie. + // this is typically used to store data needed for signout from those protocols. + var additionalLocalClaims = new List(); + var localSignInProps = new AuthenticationProperties { - // PM-24579: remove this else block with feature flag removal. - // Either the user already authenticated with the SSO provider, or we've just provisioned them. - // Either way, we have associated the SSO login with a Bitwarden user. - // We will now sign the Bitwarden user in. - if (possibleSsoLinkedUser != null) + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1) + }; + ProcessLoginCallback(result, additionalLocalClaims, localSignInProps); + + // Issue authentication cookie for user + await HttpContext.SignInAsync( + new IdentityServerUser(resolvedSsoLinkedUser.Id.ToString()) { - // This allows us to collect any additional claims or properties - // for the specific protocols used and store them in the local auth cookie. - // this is typically used to store data needed for signout from those protocols. - var additionalLocalClaims = new List(); - var localSignInProps = new AuthenticationProperties - { - IsPersistent = true, - ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1) - }; - ProcessLoginCallback(result, additionalLocalClaims, localSignInProps); - - // Issue authentication cookie for user - await HttpContext.SignInAsync( - new IdentityServerUser(possibleSsoLinkedUser.Id.ToString()) - { - DisplayName = possibleSsoLinkedUser.Email, - IdentityProvider = provider, - AdditionalClaims = additionalLocalClaims.ToArray() - }, localSignInProps); - } - } + DisplayName = resolvedSsoLinkedUser.Email, + IdentityProvider = provider, + AdditionalClaims = additionalLocalClaims.ToArray() + }, localSignInProps); // Delete temporary cookie used during external authentication await HttpContext.SignOutAsync(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index 66cb018923f1..d36cfa9cd71d 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -129,111 +129,8 @@ private static void ConfigureSsoAndUser( } } - private enum MeasurementScenario - { - ExistingSsoLinkedAccepted, - ExistingUserNoOrgUser, - JitProvision - } - - private sealed class LookupCounts - { - public int UserGetBySso { get; init; } - public int UserGetByEmail { get; init; } - public int OrgGetById { get; init; } - public int OrgUserGetByOrg { get; init; } - public int OrgUserGetByEmail { get; init; } - } - - private async Task MeasureCountsForScenarioAsync( - SutProvider sutProvider, - MeasurementScenario scenario, - bool preventNonCompliant) - { - var orgId = Guid.NewGuid(); - var providerUserId = $"meas-{scenario}-{(preventNonCompliant ? "on" : "off")}"; - var email = scenario == MeasurementScenario.JitProvision - ? "jit.compare@example.com" - : "existing.compare@example.com"; - - var organization = new Organization { Id = orgId, Name = "Org" }; - var user = new User { Id = Guid.NewGuid(), Email = email }; - - var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, email); - SetupHttpContextWithAuth(sutProvider, authResult); - - // SSO config present - var ssoConfigRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var organizationUserRepository = sutProvider.GetDependency(); - var featureService = sutProvider.GetDependency(); - var interactionService = sutProvider.GetDependency(); - - var ssoConfig = new SsoConfig { OrganizationId = orgId, Enabled = true }; - var ssoData = new SsoConfigurationData(); - ssoConfig.SetData(ssoData); - ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); - - switch (scenario) - { - case MeasurementScenario.ExistingSsoLinkedAccepted: - userRepository.GetBySsoUserAsync(providerUserId, orgId).Returns(user); - organizationRepository.GetByIdAsync(orgId).Returns(organization); - organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) - .Returns(new OrganizationUser - { - OrganizationId = orgId, - UserId = user.Id, - Status = OrganizationUserStatusType.Accepted, - Type = OrganizationUserType.User - }); - break; - case MeasurementScenario.ExistingUserNoOrgUser: - userRepository.GetBySsoUserAsync(providerUserId, orgId).Returns(user); - organizationRepository.GetByIdAsync(orgId).Returns(organization); - organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) - .Returns((OrganizationUser?)null); - break; - case MeasurementScenario.JitProvision: - userRepository.GetBySsoUserAsync(providerUserId, orgId).Returns((User?)null); - userRepository.GetByEmailAsync(email).Returns((User?)null); - organizationRepository.GetByIdAsync(orgId).Returns(organization); - organizationUserRepository.GetByOrganizationEmailAsync(orgId, email) - .Returns((OrganizationUser?)null); - break; - } - - featureService.IsEnabled(Arg.Any()).Returns(preventNonCompliant); - interactionService.GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); - - try - { - _ = await sutProvider.Sut.ExternalCallback(); - } - catch - { - // Ignore exceptions for measurement; some flows can throw based on status enforcement - } - - var counts = new LookupCounts - { - UserGetBySso = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetBySsoUserAsync)), - UserGetByEmail = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetByEmailAsync)), - OrgGetById = organizationRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationRepository.GetByIdAsync)), - OrgUserGetByOrg = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationAsync)), - OrgUserGetByEmail = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationEmailAsync)), - }; - - userRepository.ClearReceivedCalls(); - organizationRepository.ClearReceivedCalls(); - organizationUserRepository.ClearReceivedCalls(); - - return counts; - } - [Theory, BitAutoData] - public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_NoOrgUser_ThrowsCouldNotFindOrganizationUser( + public async Task ExternalCallback_ExistingUser_NoOrgUser_ThrowsCouldNotFindOrganizationUser( SutProvider sutProvider) { // Arrange @@ -262,7 +159,6 @@ public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_NoOrgUse sutProvider.GetDependency() .GetByOrganizationAsync(organization.Id, user.Id).Returns((OrganizationUser?)null); - sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); sutProvider.GetDependency() .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); @@ -272,7 +168,7 @@ public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_NoOrgUse } [Theory, BitAutoData] - public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserInvited_AllowsLogin( + public async Task ExternalCallback_ExistingUser_OrgUserInvited_AllowsLogin( SutProvider sutProvider) { // Arrange @@ -303,7 +199,6 @@ public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserI organization, orgUser); - sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); sutProvider.GetDependency() .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); @@ -327,7 +222,7 @@ await authService.Received().SignOutAsync( } [Theory, BitAutoData] - public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserRevoked_ThrowsAccessRevoked( + public async Task ExternalCallback_ExistingUser_OrgUserRevoked_ThrowsAccessRevoked( SutProvider sutProvider) { // Arrange @@ -358,7 +253,6 @@ public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserR organization, orgUser); - sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); sutProvider.GetDependency() .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); @@ -368,7 +262,7 @@ public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserR } [Theory, BitAutoData] - public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserUnknown_ThrowsUnknown( + public async Task ExternalCallback_ExistingUser_OrgUserUnknown_ThrowsUnknown( SutProvider sutProvider) { // Arrange @@ -400,7 +294,6 @@ public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserU organization, orgUser); - sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); sutProvider.GetDependency() .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); @@ -437,7 +330,6 @@ public async Task ExternalCallback_WithExistingUserAndAcceptedMembership_Redirec organization, orgUser); - sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); sutProvider.GetDependency() .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); @@ -460,54 +352,8 @@ await authService.Received().SignOutAsync( Arg.Any()); } - /// - /// PM-24579: Temporary test, remove with feature flag. - /// [Theory, BitAutoData] - public async Task ExternalCallback_PreventNonCompliantFalse_SkipsOrgLookupAndSignsIn( - SutProvider sutProvider) - { - // Arrange - var orgId = Guid.NewGuid(); - var providerUserId = "ext-flag-off"; - var user = new User { Id = Guid.NewGuid(), Email = "flagoff@example.com" }; - - var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); - var authService = SetupHttpContextWithAuth(sutProvider, authResult); - - ConfigureSsoAndUser( - sutProvider, - orgId, - providerUserId, - user); - - sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(false); - sutProvider.GetDependency() - .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); - - // Act - var result = await sutProvider.Sut.ExternalCallback(); - - // Assert - var redirect = Assert.IsType(result); - Assert.Equal("~/", redirect.Url); - - await authService.Received().SignInAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() - .GetByOrganizationAsync(Guid.Empty, Guid.Empty); - } - - /// - /// PM-24579: Permanent test, remove the True in PreventNonCompliantTrue and remove the configure for the feature - /// flag. - /// - [Theory, BitAutoData] - public async Task ExternalCallback_PreventNonCompliantTrue_ExistingSsoLinkedAccepted_MeasureLookups( + public async Task ExternalCallback_ExistingSsoLinkedAccepted_MeasureLookups( SutProvider sutProvider) { // Arrange @@ -534,7 +380,6 @@ public async Task ExternalCallback_PreventNonCompliantTrue_ExistingSsoLinkedAcce organization, orgUser); - sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); sutProvider.GetDependency() .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); @@ -574,12 +419,8 @@ public async Task ExternalCallback_PreventNonCompliantTrue_ExistingSsoLinkedAcce Assert.Equal(0, orgUserGetByEmail); } - /// - /// PM-24579: Permanent test, remove the True in PreventNonCompliantTrue and remove the configure for the feature - /// flag. - /// [Theory, BitAutoData] - public async Task ExternalCallback_PreventNonCompliantTrue_JitProvision_MeasureLookups( + public async Task ExternalCallback_JitProvision_MeasureLookups( SutProvider sutProvider) { // Arrange @@ -607,7 +448,6 @@ public async Task ExternalCallback_PreventNonCompliantTrue_JitProvision_MeasureL organizationRepository.GetByIdAsync(orgId).Returns(organization); organizationUserRepository.GetByOrganizationEmailAsync(orgId, email).Returns((OrganizationUser?)null); - sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); sutProvider.GetDependency() .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); @@ -642,15 +482,8 @@ public async Task ExternalCallback_PreventNonCompliantTrue_JitProvision_MeasureL Assert.Equal(1, orgUserGetByEmail); } - /// - /// PM-24579: Permanent test, remove the True in PreventNonCompliantTrue and remove the configure for the feature - /// flag. - /// - /// This test will trigger both the GetByOrganizationAsync and the fallback attempt to get by email - /// GetByOrganizationEmailAsync. - /// [Theory, BitAutoData] - public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_NoOrgUser_MeasureLookups( + public async Task ExternalCallback_ExistingUser_NoOrgUser_MeasureLookups( SutProvider sutProvider) { // Arrange @@ -674,7 +507,6 @@ public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_NoOrgUse sutProvider.GetDependency() .GetByOrganizationAsync(organization.Id, user.Id).Returns((OrganizationUser?)null); - sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); sutProvider.GetDependency() .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); @@ -714,150 +546,6 @@ public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_NoOrgUse Assert.Equal(1, orgUserGetByEmail); } - /// - /// PM-24579: Temporary test, remove with feature flag. - /// - [Theory, BitAutoData] - public async Task ExternalCallback_PreventNonCompliantFalse_ExistingSsoLinkedAccepted_MeasureLookups( - SutProvider sutProvider) - { - // Arrange - var orgId = Guid.NewGuid(); - var providerUserId = "ext-measure-existing-flagoff"; - var user = new User { Id = Guid.NewGuid(), Email = "existing.flagoff@example.com" }; - - var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); - SetupHttpContextWithAuth(sutProvider, authResult); - - var ssoConfig = new SsoConfig { OrganizationId = orgId, Enabled = true }; - var ssoData = new SsoConfigurationData(); - ssoConfig.SetData(ssoData); - sutProvider.GetDependency().GetByOrganizationIdAsync(orgId).Returns(ssoConfig); - sutProvider.GetDependency().GetBySsoUserAsync(providerUserId, orgId).Returns(user); - - sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(false); - sutProvider.GetDependency() - .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); - - // Act - try { _ = await sutProvider.Sut.ExternalCallback(); } catch { } - - // Assert (measurement) - var userRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var organizationUserRepository = sutProvider.GetDependency(); - - var userGetBySso = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetBySsoUserAsync)); - var userGetByEmail = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetByEmailAsync)); - var orgGet = organizationRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationRepository.GetByIdAsync)); - var orgUserGetByOrg = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationAsync)); - var orgUserGetByEmail = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationEmailAsync)); - - _output.WriteLine($"[flag off] GetBySsoUserAsync: {userGetBySso}"); - _output.WriteLine($"[flag off] GetByEmailAsync: {userGetByEmail}"); - _output.WriteLine($"[flag off] GetByIdAsync (Org): {orgGet}"); - _output.WriteLine($"[flag off] GetByOrganizationAsync (OrgUser): {orgUserGetByOrg}"); - _output.WriteLine($"[flag off] GetByOrganizationEmailAsync (OrgUser): {orgUserGetByEmail}"); - } - - /// - /// PM-24579: Temporary test, remove with feature flag. - /// - [Theory, BitAutoData] - public async Task ExternalCallback_PreventNonCompliantFalse_ExistingUser_NoOrgUser_MeasureLookups( - SutProvider sutProvider) - { - // Arrange - var orgId = Guid.NewGuid(); - var providerUserId = "ext-measure-existing-no-orguser-flagoff"; - var user = new User { Id = Guid.NewGuid(), Email = "existing2.flagoff@example.com" }; - - var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); - SetupHttpContextWithAuth(sutProvider, authResult); - - var ssoConfig = new SsoConfig { OrganizationId = orgId, Enabled = true }; - var ssoData = new SsoConfigurationData(); - ssoConfig.SetData(ssoData); - sutProvider.GetDependency().GetByOrganizationIdAsync(orgId).Returns(ssoConfig); - sutProvider.GetDependency().GetBySsoUserAsync(providerUserId, orgId).Returns(user); - - sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(false); - sutProvider.GetDependency() - .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); - - // Act - try { _ = await sutProvider.Sut.ExternalCallback(); } catch { } - - // Assert (measurement) - var userRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var organizationUserRepository = sutProvider.GetDependency(); - - var userGetBySso = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetBySsoUserAsync)); - var userGetByEmail = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetByEmailAsync)); - var orgGet = organizationRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationRepository.GetByIdAsync)); - var orgUserGetByOrg = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationAsync)); - var orgUserGetByEmail = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationEmailAsync)); - - _output.WriteLine($"[flag off] GetBySsoUserAsync: {userGetBySso}"); - _output.WriteLine($"[flag off] GetByEmailAsync: {userGetByEmail}"); - _output.WriteLine($"[flag off] GetByIdAsync (Org): {orgGet}"); - _output.WriteLine($"[flag off] GetByOrganizationAsync (OrgUser): {orgUserGetByOrg}"); - _output.WriteLine($"[flag off] GetByOrganizationEmailAsync (OrgUser): {orgUserGetByEmail}"); - } - - /// - /// PM-24579: Temporary test, remove with feature flag. - /// - [Theory, BitAutoData] - public async Task ExternalCallback_PreventNonCompliantFalse_JitProvision_MeasureLookups( - SutProvider sutProvider) - { - // Arrange - var orgId = Guid.NewGuid(); - var providerUserId = "ext-measure-jit-flagoff"; - var email = "jit.flagoff@example.com"; - var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; - - var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, email); - SetupHttpContextWithAuth(sutProvider, authResult); - - var ssoConfig = new SsoConfig { OrganizationId = orgId, Enabled = true }; - var ssoData = new SsoConfigurationData(); - ssoConfig.SetData(ssoData); - sutProvider.GetDependency().GetByOrganizationIdAsync(orgId).Returns(ssoConfig); - - // JIT (no existing user or sso link) - sutProvider.GetDependency().GetBySsoUserAsync(providerUserId, orgId).Returns((User?)null); - sutProvider.GetDependency().GetByEmailAsync(email).Returns((User?)null); - sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); - sutProvider.GetDependency().GetByOrganizationEmailAsync(orgId, email).Returns((OrganizationUser?)null); - - sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(false); - sutProvider.GetDependency() - .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); - - // Act - try { _ = await sutProvider.Sut.ExternalCallback(); } catch { } - - // Assert (measurement) - var userRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var organizationUserRepository = sutProvider.GetDependency(); - - var userGetBySso = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetBySsoUserAsync)); - var userGetByEmail = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetByEmailAsync)); - var orgGet = organizationRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationRepository.GetByIdAsync)); - var orgUserGetByOrg = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationAsync)); - var orgUserGetByEmail = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationEmailAsync)); - - _output.WriteLine($"[flag off] GetBySsoUserAsync: {userGetBySso}"); - _output.WriteLine($"[flag off] GetByEmailAsync: {userGetByEmail}"); - _output.WriteLine($"[flag off] GetByIdAsync (Org): {orgGet}"); - _output.WriteLine($"[flag off] GetByOrganizationAsync (OrgUser): {orgUserGetByOrg}"); - _output.WriteLine($"[flag off] GetByOrganizationEmailAsync (OrgUser): {orgUserGetByEmail}"); - } - [Theory, BitAutoData] public async Task CreateUserAndOrgUserConditionallyAsync_WithExistingAcceptedUser_CreatesSsoLinkAndReturnsUser( SutProvider sutProvider) @@ -976,41 +664,6 @@ public async Task CreateUserAndOrgUserConditionallyAsync_WithExistingInvitedUser Assert.Equal("AcceptInviteBeforeUsingSSO", ex.Message); } - /// - /// PM-24579: Temporary comparison test to ensure the feature flag ON does not - /// regress lookup counts compared to OFF. When removing the flag, delete this - /// comparison test and keep the specific scenario snapshot tests if desired. - /// - [Theory, BitAutoData] - public async Task ExternalCallback_Measurements_FlagOnVsOff_Comparisons( - SutProvider sutProvider) - { - // Arrange - var scenarios = new[] - { - MeasurementScenario.ExistingSsoLinkedAccepted, - MeasurementScenario.ExistingUserNoOrgUser, - MeasurementScenario.JitProvision - }; - - foreach (var scenario in scenarios) - { - // Act - var onCounts = await MeasureCountsForScenarioAsync(sutProvider, scenario, preventNonCompliant: true); - var offCounts = await MeasureCountsForScenarioAsync(sutProvider, scenario, preventNonCompliant: false); - - // Assert: off should not exceed on in any measured lookup type - Assert.True(offCounts.UserGetBySso <= onCounts.UserGetBySso, $"{scenario}: off UserGetBySso={offCounts.UserGetBySso} > on {onCounts.UserGetBySso}"); - Assert.True(offCounts.UserGetByEmail <= onCounts.UserGetByEmail, $"{scenario}: off UserGetByEmail={offCounts.UserGetByEmail} > on {onCounts.UserGetByEmail}"); - Assert.True(offCounts.OrgGetById <= onCounts.OrgGetById, $"{scenario}: off OrgGetById={offCounts.OrgGetById} > on {onCounts.OrgGetById}"); - Assert.True(offCounts.OrgUserGetByOrg <= onCounts.OrgUserGetByOrg, $"{scenario}: off OrgUserGetByOrg={offCounts.OrgUserGetByOrg} > on {onCounts.OrgUserGetByOrg}"); - Assert.True(offCounts.OrgUserGetByEmail <= onCounts.OrgUserGetByEmail, $"{scenario}: off OrgUserGetByEmail={offCounts.OrgUserGetByEmail} > on {onCounts.OrgUserGetByEmail}"); - - _output.WriteLine($"Scenario={scenario} | ON: SSO={onCounts.UserGetBySso}, Email={onCounts.UserGetByEmail}, Org={onCounts.OrgGetById}, OrgUserByOrg={onCounts.OrgUserGetByOrg}, OrgUserByEmail={onCounts.OrgUserGetByEmail}"); - _output.WriteLine($"Scenario={scenario} | OFF: SSO={offCounts.UserGetBySso}, Email={offCounts.UserGetByEmail}, Org={offCounts.OrgGetById}, OrgUserByOrg={offCounts.OrgUserGetByOrg}, OrgUserByEmail={offCounts.OrgUserGetByEmail}"); - } - } - [Theory, BitAutoData] public void ExternalChallenge_WithMatchingOrgId_Succeeds( SutProvider sutProvider, diff --git a/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs index 7a1c9f9628a4..7364ff8ceae2 100644 --- a/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs +++ b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs @@ -1,5 +1,4 @@ using System.Net; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Data; @@ -59,36 +58,6 @@ public async Task ExternalCallback_WithNoAuthenticationCookie_ReturnsError() Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); } - /* - * Test to verify behavior of /Account/ExternalCallback with PM24579 feature flag - */ - [Theory] - [BitAutoData(true)] - [BitAutoData(false)] - public async Task ExternalCallback_WithPM24579FeatureFlag_AndNoAuthCookie_ReturnsError - ( - bool featureFlagEnabled - ) - { - // Arrange - var client = _factory.WithWebHostBuilder(builder => - { - builder.ConfigureServices(services => - { - var featureService = Substitute.For(); - featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(featureFlagEnabled); - services.AddSingleton(featureService); - }); - }).CreateClient(); - - // Act - var response = await client.GetAsync("/Account/ExternalCallback"); - - // Assert - Assert.False(response.IsSuccessStatusCode); - Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); - } - /* * Test to verify behavior of /Account/ExternalCallback simulating failed authentication. */ @@ -526,10 +495,6 @@ public async Task ExternalCallback_WithUserIdentifier_AndInvalidToken_ReturnsErr { builder.ConfigureServices(services => { - var featureService = Substitute.For(); - featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); - services.AddSingleton(featureService); - // Mock organization repository var orgRepo = Substitute.For(); orgRepo.GetByIdAsync(organizationId).Returns(organization); @@ -582,10 +547,10 @@ public async Task ExternalCallback_WithUserIdentifier_AndInvalidToken_ReturnsErr } /* - * Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is enabled. + * Test to verify /Account/ExternalCallback returns error for revoked org user. */ [Fact] - public async Task ExternalCallback_WithRevokedOrgUser_WithPM24579FeatureFlagEnabled_ReturnsError() + public async Task ExternalCallback_WithRevokedOrgUser_ReturnsError() { // Arrange var testData = await new SsoTestDataBuilder() @@ -595,13 +560,6 @@ public async Task ExternalCallback_WithRevokedOrgUser_WithPM24579FeatureFlagEnab { orgUser.Status = OrganizationUserStatusType.Revoked; }) - .WithFeatureFlags(factoryService => - { - factoryService.SubstituteService(srv => - { - srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); - }); - }) .BuildAsync(); var client = testData.Factory.CreateClient(); @@ -617,98 +575,18 @@ public async Task ExternalCallback_WithRevokedOrgUser_WithPM24579FeatureFlagEnab Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); } - /* - * Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is disabled. - */ - [Fact] - public async Task ExternalCallback_WithRevokedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError() - { - // Arrange - var testData = await new SsoTestDataBuilder() - .WithSsoConfig() - .WithUser() - .WithOrganizationUser(orgUser => - { - orgUser.Status = OrganizationUserStatusType.Revoked; - }) - .WithFeatureFlags(factoryService => - { - factoryService.SubstituteService(srv => - { - srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false); - }); - }) - .BuildAsync(); - - var client = testData.Factory.CreateClient(); - - // Act - var response = await client.GetAsync("/Account/ExternalCallback"); - - // Assert - Should fail because user has invalid status - var stringResponse = await response.Content.ReadAsStringAsync(); - Assert.Contains( - $"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.", - stringResponse); - Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); - } - - /* - * Test to verify /Account/ExternalCallback returns error for invited org user when PM24579 feature flag is disabled. - */ - [Fact] - public async Task ExternalCallback_WithInvitedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError() - { - // Arrange - var testData = await new SsoTestDataBuilder() - .WithSsoConfig() - .WithUser() - .WithOrganizationUser(orgUser => - { - orgUser.Status = OrganizationUserStatusType.Invited; - }) - .WithFeatureFlags(factoryService => - { - factoryService.SubstituteService(srv => - { - srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false); - }); - }) - .BuildAsync(); - - var client = testData.Factory.CreateClient(); - - // Act - var response = await client.GetAsync("/Account/ExternalCallback"); - - // Assert - Should fail because user has invalid status - var stringResponse = await response.Content.ReadAsStringAsync(); - Assert.Contains( - $"To accept your invite to {testData.Organization?.DisplayName()}, you must first log in using your master password. Once your invite has been accepted, you will be able to log in using SSO.", - stringResponse); - Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); - } - - /* * Test to verify /Account/ExternalCallback returns error when user is found via SSO - * but has no organization user record (with feature flag enabled). + * but has no organization user record. */ [Fact] - public async Task ExternalCallback_WithSsoUser_AndNoOrgUser_WithFeatureFlagEnabled_ReturnsError() + public async Task ExternalCallback_WithSsoUser_AndNoOrgUser_ReturnsError() { // Arrange var testData = await new SsoTestDataBuilder() .WithSsoConfig() .WithUser() .WithSsoUser() - .WithFeatureFlags(factoryService => - { - factoryService.SubstituteService(srv => - { - srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); - }); - }) .BuildAsync(); var client = testData.Factory.CreateClient(); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3077a49ab310..8baa4e1fc464 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -157,7 +157,6 @@ 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 PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users"; public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; public const string PM2035PasskeyUnlock = "pm-2035-passkey-unlock"; public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email"; From f4c8ef44b0f4196fa2dcbf38a6c79437b74319f1 Mon Sep 17 00:00:00 2001 From: Jared Date: Mon, 9 Mar 2026 11:41:29 -0400 Subject: [PATCH 35/85] [PM-25860] Rid of bulk delete error (#6925) * Rid of bulk delete error * Fix test * Fix for test * Update src/Core/Dirt/Services/Implementations/EventService.cs Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * Fix formatting issues in DeleteCollectionCommandTests.cs by removing hidden characters and ensuring proper using directives. * Update src/Core/Dirt/Services/Implementations/EventService.cs Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * Update src/Core/Dirt/Services/Implementations/EventService.cs Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * Refactor DeleteCollectionCommandTests.cs to remove hidden characters and improve argument matching for GetManyByManyIdsAsync method. * Fix deletion error happening in Postgres by utilizing OrganizationId which is always populated by the table row --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .../Services/Implementations/EventService.cs | 38 ++++++++- .../DeleteCollectionCommand.cs | 27 ++++++- .../Repositories/CollectionRepository.cs | 2 +- .../DeleteCollectionCommandTests.cs | 78 +++++++++++++++++-- 4 files changed, 132 insertions(+), 13 deletions(-) diff --git a/src/Core/Dirt/Services/Implementations/EventService.cs b/src/Core/Dirt/Services/Implementations/EventService.cs index 5904046512a8..dbab9286bcd8 100644 --- a/src/Core/Dirt/Services/Implementations/EventService.cs +++ b/src/Core/Dirt/Services/Implementations/EventService.cs @@ -152,8 +152,23 @@ public async Task LogCollectionEventAsync(Collection collection, EventType type, public async Task LogCollectionEventsAsync(IEnumerable<(Collection collection, EventType type, DateTime? date)> events) { var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + + // Batch lookup provider IDs for all unique organization IDs upfront + var materializedEvents = events.ToList(); + var uniqueOrgIds = materializedEvents + .Select(e => e.collection.OrganizationId) + .Distinct() + .Where(orgId => CanUseEvents(orgAbilities, orgId)) + .ToList(); + + var providerIds = new Dictionary(); + foreach (var orgId in uniqueOrgIds) + { + providerIds[orgId] = await GetProviderIdAsync(orgId); + } + var eventMessages = new List(); - foreach (var (collection, type, date) in events) + foreach (var (collection, type, date) in materializedEvents) { if (!CanUseEvents(orgAbilities, collection.OrganizationId)) { @@ -166,7 +181,7 @@ public async Task LogCollectionEventsAsync(IEnumerable<(Collection collection, E CollectionId = collection.Id, Type = type, ActingUserId = _currentContext?.UserId, - ProviderId = await GetProviderIdAsync(collection.OrganizationId), + ProviderId = providerIds.GetValueOrDefault(collection.OrganizationId), Date = date.GetValueOrDefault(DateTime.UtcNow) }); } @@ -183,8 +198,23 @@ public async Task LogGroupEventAsync(Group group, EventType type, EventSystemUse public async Task LogGroupEventsAsync(IEnumerable<(Group group, EventType type, EventSystemUser? systemUser, DateTime? date)> events) { var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + + // Batch lookup provider IDs for all unique organization IDs upfront + var materializedEvents = events.ToList(); + var uniqueOrgIds = materializedEvents + .Select(e => e.group.OrganizationId) + .Distinct() + .Where(orgId => CanUseEvents(orgAbilities, orgId)) + .ToList(); + + var providerIds = new Dictionary(); + foreach (var orgId in uniqueOrgIds) + { + providerIds[orgId] = await GetProviderIdAsync(orgId); + } + var eventMessages = new List(); - foreach (var (group, type, systemUser, date) in events) + foreach (var (group, type, systemUser, date) in materializedEvents) { if (!CanUseEvents(orgAbilities, group.OrganizationId)) { @@ -197,7 +227,7 @@ public async Task LogGroupEventsAsync(IEnumerable<(Group group, EventType type, GroupId = group.Id, Type = type, ActingUserId = _currentContext?.UserId, - ProviderId = await GetProviderIdAsync(group.OrganizationId), + ProviderId = providerIds.GetValueOrDefault(group.OrganizationId), SystemUser = systemUser, Date = date.GetValueOrDefault(DateTime.UtcNow) }; diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommand.cs index 4f678633a919..be65c35c1b41 100644 --- a/src/Core/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommand.cs @@ -4,6 +4,7 @@ using Bit.Core.OrganizationFeatures.OrganizationCollections.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; +using Microsoft.Extensions.Logging; namespace Bit.Core.OrganizationFeatures.OrganizationCollections; @@ -11,13 +12,16 @@ public class DeleteCollectionCommand : IDeleteCollectionCommand { private readonly ICollectionRepository _collectionRepository; private readonly IEventService _eventService; + private readonly ILogger _logger; public DeleteCollectionCommand( ICollectionRepository collectionRepository, - IEventService eventService) + IEventService eventService, + ILogger logger) { _collectionRepository = collectionRepository; _eventService = eventService; + _logger = logger; } public async Task DeleteAsync(Collection collection) @@ -28,7 +32,15 @@ public async Task DeleteAsync(Collection collection) } await _collectionRepository.DeleteAsync(collection); - await _eventService.LogCollectionEventAsync(collection, Enums.EventType.Collection_Deleted, DateTime.UtcNow); + + try + { + await _eventService.LogCollectionEventAsync(collection, Enums.EventType.Collection_Deleted, DateTime.UtcNow); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to log collection deletion event for collection {CollectionId}", collection.Id); + } } public async Task DeleteManyAsync(IEnumerable collectionIds) @@ -46,6 +58,15 @@ public async Task DeleteManyAsync(IEnumerable collections) } await _collectionRepository.DeleteManyAsync(collections.Select(c => c.Id)); - await _eventService.LogCollectionEventsAsync(collections.Select(c => (c, Enums.EventType.Collection_Deleted, (DateTime?)DateTime.UtcNow))); + + try + { + await _eventService.LogCollectionEventsAsync(collections.Select(c => (c, Enums.EventType.Collection_Deleted, (DateTime?)DateTime.UtcNow))); + } + catch (Exception ex) + { + var collectionIds = string.Join(", ", collections.Select(c => c.Id)); + _logger.LogError(ex, "Failed to log collection deletion events for collections: {CollectionIds}", collectionIds); + } } } diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 141928b78a22..67441fb3f53c 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -622,7 +622,7 @@ public async Task DeleteManyAsync(IEnumerable collectionIds) dbContext.Collections.RemoveRange(collectionEntities); await dbContext.SaveChangesAsync(); - foreach (var collection in collectionEntities.GroupBy(g => g.Organization.Id)) + foreach (var collection in collectionEntities.GroupBy(g => g.OrganizationId)) { await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(collection.Key); } diff --git a/test/Core.Test/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommandTests.cs index efe9223f1b58..4349e173df8e 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationCollections/DeleteCollectionCommandTests.cs @@ -16,7 +16,8 @@ namespace Bit.Core.Test.OrganizationFeatures.OrganizationConnections; public class DeleteCollectionCommandTests { - [Theory, BitAutoData] + [Theory] + [BitAutoData] [OrganizationCustomize] public async Task DeleteAsync_DeletesCollection(Collection collection, SutProvider sutProvider) { @@ -28,7 +29,8 @@ public async Task DeleteAsync_DeletesCollection(Collection collection, SutProvid await sutProvider.GetDependency().Received().LogCollectionEventAsync(collection, EventType.Collection_Deleted, Arg.Any()); } - [Theory, BitAutoData] + [Theory] + [BitAutoData] [OrganizationCustomize] public async Task DeleteManyAsync_DeletesManyCollections(Collection collection, Collection collection2, SutProvider sutProvider) { @@ -45,14 +47,15 @@ public async Task DeleteManyAsync_DeletesManyCollections(Collection collection, // Assert await sutProvider.GetDependency().Received() - .DeleteManyAsync(Arg.Is>(ids => ids.SequenceEqual(collectionIds))); + .DeleteManyAsync(Arg.Is>(ids => ids.ToArray().SequenceEqual(collectionIds))); await sutProvider.GetDependency().Received().LogCollectionEventsAsync( Arg.Is>(a => a.All(c => collectionIds.Contains(c.Item1.Id) && c.Item2 == EventType.Collection_Deleted))); } - [Theory, BitAutoData] + [Theory] + [BitAutoData] [OrganizationCustomize] public async Task DeleteAsync_WithDefaultUserCollectionType_ThrowsBadRequest(Collection collection, SutProvider sutProvider) { @@ -70,7 +73,8 @@ await sutProvider.GetDependency() .LogCollectionEventAsync(default, default, default); } - [Theory, BitAutoData] + [Theory] + [BitAutoData] [OrganizationCustomize] public async Task DeleteManyAsync_WithDefaultUserCollectionType_ThrowsBadRequest(Collection collection, Collection collection2, SutProvider sutProvider) { @@ -90,4 +94,68 @@ await sutProvider.GetDependency() .LogCollectionEventsAsync(default); } + [Theory] + [BitAutoData] + [OrganizationCustomize] + public async Task DeleteManyAsync_WithManyCollections_DeletesAllCollections(SutProvider sutProvider) + { + // Arrange - Create 100 collections to test bulk delete performance + var collections = new List(); + var collectionIds = new List(); + + for (int i = 0; i < 100; i++) + { + var collection = new Collection + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + Type = CollectionType.SharedCollection, + Name = $"Collection {i}" + }; + collections.Add(collection); + collectionIds.Add(collection.Id); + } + + sutProvider.GetDependency() + .GetManyByManyIdsAsync(Arg.Is>(ids => ids.SequenceEqual(collectionIds))) + .Returns(collections); + + // Act + await sutProvider.Sut.DeleteManyAsync(collectionIds); + + // Assert + await sutProvider.GetDependency().Received() + .DeleteManyAsync(Arg.Is>(ids => ids.ToArray().SequenceEqual(collectionIds.ToArray()))); + + await sutProvider.GetDependency().Received().LogCollectionEventsAsync( + Arg.Is>(a => + a.Count() == 100 && + a.All(c => collectionIds.Contains(c.Item1.Id) && c.Item2 == EventType.Collection_Deleted))); + } + + [Theory] + [BitAutoData] + [OrganizationCustomize] + public async Task DeleteManyAsync_WhenEventLoggingFails_StillDeletesCollections(Collection collection, SutProvider sutProvider) + { + // Arrange + var collectionIds = new[] { collection.Id }; + collection.Type = CollectionType.SharedCollection; + + sutProvider.GetDependency() + .GetManyByManyIdsAsync(collectionIds) + .Returns(new List { collection }); + + sutProvider.GetDependency() + .LogCollectionEventsAsync(Arg.Any>()) + .Returns(_ => throw new Exception("Event logging failed")); + + // Act - Should not throw exception even though event logging fails + await sutProvider.Sut.DeleteManyAsync(collectionIds); + + // Assert - Collections should still be deleted + await sutProvider.GetDependency().Received() + .DeleteManyAsync(Arg.Is>(ids => ids.ToArray().SequenceEqual(collectionIds))); + } + } From 2d4f3e05ff3ef49b91b761bea91fc4a76027590d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:51:52 -0500 Subject: [PATCH 36/85] [deps]: Update MarkDig to 0.45.0 (#7117) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Billing/Billing.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index dee50af0bb1b..a7ea9f654eb1 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -20,7 +20,7 @@ - + From cc3759bc5d0b430ed631595badf92d2494e8c004 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Mon, 9 Mar 2026 10:55:55 -0500 Subject: [PATCH 37/85] [PM-18236] - Use Single Org Requirement (#6999) * Added new methods and ff for single org req * Changed req messages and added new method for creating orgs * Updated Requirement and Tests. * Updated commands and requirement to take a list of org users * Updated xml docs and renamed to be consistent * Changes from Code Review * Removed feature flag check for policy requirements around single org. Aligned error message with what other commands were returning. * Fixed test names. Updated error messages to be specific for each caller. * Updated tests to clean up details consturction * Added test for confirmed accepted user in another org. * fixed tests to use new factory * Update test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * Fixed tests by adding no op for req. --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .../OrganizationUsers/AcceptOrgUserCommand.cs | 36 +-- .../ConfirmOrganizationUserCommand.cs | 29 ++- .../v1/RestoreOrganizationUserCommand.cs | 55 ++--- .../CloudOrganizationSignUpCommand.cs | 11 +- .../InitPendingOrganizationCommand.cs | 13 +- .../SelfHostedOrganizationSignUpCommand.cs | 12 +- .../Errors/SingleOrganizationPolicyErrors.cs | 13 + .../SingleOrganizationPolicyRequirement.cs | 68 +++++- .../AcceptOrgUserCommandTests.cs | 174 ++++++++------ .../ConfirmOrganizationUserCommandTests.cs | 222 ++++++++++-------- .../RestoreOrganizationUserCommandTests.cs | 178 ++++++-------- .../InitPendingOrganizationCommandTests.cs | 79 +++++++ .../CloudOrganizationSignUpCommandTests.cs | 81 +++++++ ...elfHostedOrganizationSignUpCommandTests.cs | 94 +++++--- ...ingleOrganizationPolicyRequirementTests.cs | 199 ++++++++++++++++ .../Policies/PolicyRequirementsHelper.cs | 35 +++ 16 files changed, 901 insertions(+), 398 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/Errors/SingleOrganizationPolicyErrors.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SingleOrganizationPolicyRequirementTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirementsHelper.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs index 1b29f379628f..241c28e6954b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommand.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements.Errors; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; @@ -178,23 +179,7 @@ public async Task AcceptOrgUserAsync(OrganizationUser orgUser, await HandleAutomaticUserConfirmationPolicyAsync(orgUser, allOrgUsers, user); } - // Enforce Single Organization Policy of organization user is trying to join - var invitedSingleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, - PolicyType.SingleOrg, OrganizationUserStatusType.Invited); - - if (allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId) - && invitedSingleOrgPolicies.Any(p => p.OrganizationId == orgUser.OrganizationId)) - { - throw new BadRequestException("You may not join this organization until you leave or remove all other organizations."); - } - - // Enforce Single Organization Policy of other organizations user is a member of - var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(user.Id, - PolicyType.SingleOrg); - if (anySingleOrgPolicies) - { - throw new BadRequestException("You cannot join this organization because you are a member of another organization which forbids it"); - } + await ValidateSingleOrganizationPolicyAsync(orgUser, allOrgUsers, user); // Enforce Two Factor Authentication Policy of organization user is trying to join await ValidateTwoFactorAuthenticationPolicyAsync(user, orgUser.OrganizationId); @@ -222,6 +207,23 @@ public async Task AcceptOrgUserAsync(OrganizationUser orgUser, return orgUser; } + private async Task ValidateSingleOrganizationPolicyAsync(OrganizationUser orgUser, ICollection allOrgUsers, User user) + { + var singleOrgRequirement = await _policyRequirementQuery.GetAsync(user.Id); + var error = singleOrgRequirement.CanJoinOrganization(orgUser.OrganizationId, allOrgUsers); + if (error is not null) + { + var singleOrgErrorMessage = error switch + { + UserIsAMemberOfAnotherOrganization => "You cannot accept this invite until you leave or remove all other organizations.", + UserIsAMemberOfAnOrganizationThatHasSingleOrgPolicy => "You cannot accept this invite because you are in another organization which forbids it.", + _ => error.Message + }; + + throw new BadRequestException(singleOrgErrorMessage); + } + } + private async Task ValidateTwoFactorAuthenticationPolicyAsync(User user, Guid organizationId) { if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index a4c1bee9427f..53c1eefc0c8b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements.Errors; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -190,13 +191,11 @@ private async Task>> SaveChangesToDatabaseA } private async Task CheckPoliciesAsync(Guid organizationId, User user, - ICollection userOrgs, bool userTwoFactorEnabled) + ICollection orgUsers, bool userTwoFactorEnabled) { // Enforce Two Factor Authentication Policy for this organization await ValidateTwoFactorAuthenticationPolicyAsync(user, organizationId, userTwoFactorEnabled); - var hasOtherOrgs = userOrgs.Any(ou => ou.OrganizationId != organizationId); - if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) { var policyRequirement = await _policyRequirementQuery.GetAsync( @@ -205,7 +204,7 @@ private async Task CheckPoliciesAsync(Guid organizationId, User user, var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync( new AutomaticUserConfirmationPolicyEnforcementRequest( organizationId, - userOrgs, + orgUsers, user), policyRequirement)) .Match( @@ -224,18 +223,18 @@ private async Task CheckPoliciesAsync(Guid organizationId, User user, } } - var singleOrgPolicies = await _policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg); - var otherSingleOrgPolicies = - singleOrgPolicies.Where(p => p.OrganizationId != organizationId); - // Enforce Single Organization Policy for this organization - if (hasOtherOrgs && singleOrgPolicies.Any(p => p.OrganizationId == organizationId)) - { - throw new BadRequestException("Cannot confirm this member to the organization until they leave or remove all other organizations."); - } - // Enforce Single Organization Policy of other organizations user is a member of - if (otherSingleOrgPolicies.Any()) + var singleOrgRequirement = await _policyRequirementQuery.GetAsync(user.Id); + var singleOrgError = singleOrgRequirement.CanJoinOrganization(organizationId, orgUsers); + if (singleOrgError is not null) { - throw new BadRequestException("Cannot confirm this member to the organization because they are in another organization which forbids it."); + var singleOrgErrorMessage = singleOrgError switch + { + UserIsAMemberOfAnotherOrganization => $"{user.Email} cannot be confirmed until they leave or remove all other organizations.", + UserIsAMemberOfAnOrganizationThatHasSingleOrgPolicy => $"{user.Email} cannot be confirmed because they are in another organization which forbids it.", + _ => singleOrgError.Message + }; + + throw new BadRequestException(singleOrgErrorMessage); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs index 55153ed5c98d..9ec3e6c4528d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements.Errors; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -312,53 +313,33 @@ private async Task CheckPoliciesBeforeRestoreAsync(OrganizationUser orgUser, boo var userId = orgUser.UserId.Value; - // Enforce Single Organization Policy of organization user is being restored to var allOrgUsers = await organizationUserRepository.GetManyByUserAsync(userId); - var hasOtherOrgs = allOrgUsers.Any(ou => ou.OrganizationId != orgUser.OrganizationId); - var singleOrgPoliciesApplyingToRevokedUsers = await policyService.GetPoliciesApplicableToUserAsync(userId, - PolicyType.SingleOrg, OrganizationUserStatusType.Revoked); - var singleOrgPolicyApplies = - singleOrgPoliciesApplyingToRevokedUsers.Any(p => p.OrganizationId == orgUser.OrganizationId); - - var singleOrgCompliant = true; - var belongsToOtherOrgCompliant = true; - var twoFactorCompliant = true; - - if (hasOtherOrgs && singleOrgPolicyApplies) - { - singleOrgCompliant = false; - } - - // Enforce Single Organization Policy of other organizations user is a member of - var anySingleOrgPolicies = await policyService.AnyPoliciesApplicableToUserAsync(userId, PolicyType.SingleOrg); - if (anySingleOrgPolicies) - { - belongsToOtherOrgCompliant = false; - } + var user = await userRepository.GetByIdAsync(userId); - // Enforce 2FA Policy of organization user is trying to join - if (!userHasTwoFactorEnabled) - { - twoFactorCompliant = !await IsTwoFactorRequiredForOrganizationAsync(userId, orgUser.OrganizationId); - } + var singleOrgRequirement = await policyRequirementQuery.GetAsync(userId); + var singleOrgError = singleOrgRequirement.CanJoinOrganization(orgUser.OrganizationId, allOrgUsers); - var user = await userRepository.GetByIdAsync(userId); + var twoFactorCompliant = userHasTwoFactorEnabled || !await IsTwoFactorRequiredForOrganizationAsync(userId, orgUser.OrganizationId); - if (!singleOrgCompliant && !twoFactorCompliant) + if (singleOrgError is not null && !twoFactorCompliant) { throw new BadRequestException(user.Email + " is not compliant with the single organization and two-step login policy"); } - else if (!singleOrgCompliant) - { - throw new BadRequestException(user.Email + " is not compliant with the single organization policy"); - } - else if (!belongsToOtherOrgCompliant) + + if (singleOrgError is not null) { - throw new BadRequestException(user.Email + - " belongs to an organization that doesn't allow them to join multiple organizations"); + var singleOrgErrorMessage = singleOrgError switch + { + UserIsAMemberOfAnotherOrganization => $"{user.Email} cannot be restored until they leave or remove all other organizations.", + UserIsAMemberOfAnOrganizationThatHasSingleOrgPolicy => $"{user.Email} cannot be restored because they are in another organization which forbids it.", + _ => singleOrgError.Message + }; + + throw new BadRequestException(singleOrgErrorMessage); } - else if (!twoFactorCompliant) + + if (!twoFactorCompliant) { throw new BadRequestException(user.Email + " is not compliant with the two-step login policy"); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index e18eba36a3e2..275a98853245 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -2,10 +2,8 @@ #nullable disable using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Services; @@ -37,7 +35,6 @@ public class CloudOrganizationSignUpCommand( IOrganizationUserRepository organizationUserRepository, IOrganizationBillingService organizationBillingService, IStripePaymentService paymentService, - IPolicyService policyService, IOrganizationRepository organizationRepository, IOrganizationApiKeyRepository organizationApiKeyRepository, IApplicationCacheService applicationCacheService, @@ -253,11 +250,11 @@ private async Task ValidateSignUpPoliciesAsync(Guid ownerId) } } - var anySingleOrgPolicies = await policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); - if (anySingleOrgPolicies) + var singleOrgRequirement = await policyRequirementQuery.GetAsync(ownerId); + var error = singleOrgRequirement.CanCreateOrganization(); + if (error is not null) { - throw new BadRequestException("You may not create an organization. You belong to an organization " + - "which has a policy that prohibits you from being a member of any other organization."); + throw new BadRequestException(error.Message); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs index 04cbae96d800..fc35940cca11 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs @@ -1,10 +1,8 @@ using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Entities; @@ -26,7 +24,6 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand private readonly ICollectionRepository _collectionRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; - private readonly IPolicyService _policyService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IFeatureService _featureService; private readonly IPolicyRequirementQuery _policyRequirementQuery; @@ -45,7 +42,6 @@ public InitPendingOrganizationCommand( ICollectionRepository collectionRepository, IOrganizationRepository organizationRepository, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, - IPolicyService policyService, IOrganizationUserRepository organizationUserRepository, IFeatureService featureService, IPolicyRequirementQuery policyRequirementQuery, @@ -63,7 +59,6 @@ public InitPendingOrganizationCommand( _collectionRepository = collectionRepository; _organizationRepository = organizationRepository; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; - _policyService = policyService; _organizationUserRepository = organizationUserRepository; _featureService = featureService; _policyRequirementQuery = policyRequirementQuery; @@ -156,11 +151,11 @@ private async Task ValidateSignUpPoliciesAsync(Guid ownerId) } } - var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); - if (anySingleOrgPolicies) + var singleOrgRequirement = await _policyRequirementQuery.GetAsync(ownerId); + var error = singleOrgRequirement.CanCreateOrganization(); + if (error is not null) { - throw new BadRequestException("You may not create an organization. You belong to an organization " + - "which has a policy that prohibits you from being a member of any other organization."); + throw new BadRequestException(error.Message); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs index 9abce991c3a4..2347eeadba1a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommand.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -30,7 +29,6 @@ public class SelfHostedOrganizationSignUpCommand : ISelfHostedOrganizationSignUp private readonly IPushNotificationService _pushNotificationService; private readonly IDeviceRepository _deviceRepository; private readonly ILicensingService _licensingService; - private readonly IPolicyService _policyService; private readonly IGlobalSettings _globalSettings; private readonly IStripePaymentService _paymentService; private readonly IFeatureService _featureService; @@ -46,7 +44,6 @@ public SelfHostedOrganizationSignUpCommand( IPushNotificationService pushNotificationService, IDeviceRepository deviceRepository, ILicensingService licensingService, - IPolicyService policyService, IGlobalSettings globalSettings, IStripePaymentService paymentService, IFeatureService featureService, @@ -61,7 +58,6 @@ public SelfHostedOrganizationSignUpCommand( _pushNotificationService = pushNotificationService; _deviceRepository = deviceRepository; _licensingService = licensingService; - _policyService = policyService; _globalSettings = globalSettings; _paymentService = paymentService; _featureService = featureService; @@ -122,11 +118,11 @@ private async Task ValidateSignUpPoliciesAsync(Guid ownerId) } } - var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg); - if (anySingleOrgPolicies) + var singleOrgRequirement = await _policyRequirementQuery.GetAsync(ownerId); + var error = singleOrgRequirement.CanCreateOrganization(); + if (error is not null) { - throw new BadRequestException("You may not create an organization. You belong to an organization " + - "which has a policy that prohibits you from being a member of any other organization."); + throw new BadRequestException(error.Message); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/Errors/SingleOrganizationPolicyErrors.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/Errors/SingleOrganizationPolicyErrors.cs new file mode 100644 index 000000000000..a028e16aa995 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/Errors/SingleOrganizationPolicyErrors.cs @@ -0,0 +1,13 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements.Errors; + +public record UserIsAMemberOfAnOrganizationThatHasSingleOrgPolicy() + : BadRequestError("Member cannot join the organization because they are in another organization which forbids it."); + +public record UserIsAMemberOfAnotherOrganization() + : BadRequestError("Member cannot join the organization until they leave or remove all other organizations."); + +public record UserCannotCreateOrg() + : BadRequestError("You may not create an organization. You belong to an organization " + + "which has a policy that prohibits you from being a member of any other organization."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SingleOrganizationPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SingleOrganizationPolicyRequirement.cs index d1e1efafd996..7eaac73db531 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SingleOrganizationPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SingleOrganizationPolicyRequirement.cs @@ -1,21 +1,81 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements.Errors; +using Bit.Core.AdminConsole.Utilities.v2; +using Bit.Core.Entities; +using Bit.Core.Enums; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; public class SingleOrganizationPolicyRequirement(IEnumerable policyDetails) : IPolicyRequirement { - public bool IsSingleOrgEnabledForThisOrganization(Guid organizationId) => - policyDetails.Any(p => p.OrganizationId == organizationId); + /// + /// Returns an error if the user cannot create an organization due to being a part of another organization. + /// + /// UserCannotCreateOrg error if the user cannot create an organization, otherwise null. + public Error? CanCreateOrganization() => policyDetails + .Any(p => p.HasStatus([OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed])) + ? new UserCannotCreateOrg() + : null; - public bool IsSingleOrgEnabledForOrganizationsOtherThan(Guid organizationId) => - policyDetails.Any(p => p.OrganizationId != organizationId); + /// + /// Returns an error if the user cannot join the organization. + /// + /// Organization the user is attempting to join. + /// All organization users that a given user is linked to. + /// + /// UserIsAMemberOfAnotherOrganization or UserIsAMemberOfAnOrganizationThatHasSingleOrgPolicy if the user cannot + /// join the organization, otherwise null. + /// + public Error? CanJoinOrganization(Guid organizationId, ICollection allOrgUsers) => + IsCompliantWithTargetOrganization(organizationId, allOrgUsers) + ?? IsEnforcedForOtherOrganizationsUserIsAPartOf(organizationId); + + /// + /// Returns true if the policy is enabled for the target organization. + /// + /// Organization Id the user is attempting to join + /// + private bool IsEnabledForTargetOrganization(Guid targetOrganizationId) => + policyDetails.Any(p => p.OrganizationId == targetOrganizationId); + + /// + /// Will return an error if the user is a member of another organization and Single Organization is enabled for the + /// target organization. + /// + /// Organization Id the user is attempting to join + /// All organization users associated with the user id + /// + /// UserIsAMemberOfAnotherOrganization if the user cannot join the target organization, otherwise null. + /// + private Error? IsCompliantWithTargetOrganization(Guid targetOrganizationId, + ICollection allOrgUsers) => + IsEnabledForTargetOrganization(targetOrganizationId) + && allOrgUsers.Any(ou => ou.OrganizationId != targetOrganizationId) + ? new UserIsAMemberOfAnotherOrganization() + : null; + + /// + /// Returns an error if the user is a member of another organization that has enabled the Single Organization policy. + /// + /// Organization Id the user is attempting to join + /// + /// UserIsAMemberOfAnOrganizationThatHasSingleOrgPolicy if the user is a member of another organization that has + /// enabled the Single Organization policy, otherwise null. + /// + private Error? IsEnforcedForOtherOrganizationsUserIsAPartOf(Guid targetOrganizationId) => + policyDetails.Any(p => p.OrganizationId != targetOrganizationId + && p.HasStatus([OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed])) + ? new UserIsAMemberOfAnOrganizationThatHasSingleOrgPolicy() + : null; } public class SingleOrganizationPolicyRequirementFactory : BasePolicyRequirementFactory { public override PolicyType PolicyType => PolicyType.SingleOrg; + protected override IEnumerable ExemptStatuses { get; } = []; + public override SingleOrganizationPolicyRequirement Create(IEnumerable policyDetails) => new(policyDetails); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs index 19da1fe988a2..5cf660b90247 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AcceptOrgUserCommandTests.cs @@ -18,6 +18,7 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Tokens; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -106,60 +107,6 @@ public async Task AcceptOrgUser_OrgUserStatusIsNotInvited_ThrowsBadRequest( Assert.Equal("Already accepted.", exception.Message); } - [Theory] - [BitAutoData] - public async Task AcceptOrgUser_UserJoiningOrgWithSingleOrgPolicyWhileInAnotherOrg_ThrowsBadRequest( - SutProvider sutProvider, - User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) - { - // Arrange - SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); - - // Make user part of another org - var otherOrgUser = new OrganizationUser { UserId = user.Id, OrganizationId = Guid.NewGuid() }; // random org ID - sutProvider.GetDependency() - .GetManyByUserAsync(user.Id) - .Returns(Task.FromResult>(new List { otherOrgUser })); - - // Make organization they are trying to join have the single org policy - var singleOrgPolicy = new OrganizationUserPolicyDetails { OrganizationId = orgUser.OrganizationId }; - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg, OrganizationUserStatusType.Invited) - .Returns(Task.FromResult>( - new List { singleOrgPolicy })); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService)); - - Assert.Equal("You may not join this organization until you leave or remove all other organizations.", - exception.Message); - } - - [Theory] - [BitAutoData] - public async Task AcceptOrgUserAsync_UserInOrgWithSingleOrgPolicyAlready_ThrowsBadRequest( - SutProvider sutProvider, - User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) - { - // Arrange - SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); - - // Mock that user is part of an org that has the single org policy - sutProvider.GetDependency() - .AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) - .Returns(true); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService)); - - Assert.Equal( - "You cannot join this organization because you are a member of another organization which forbids it", - exception.Message); - } - - [Theory] [BitAutoData] public async Task AcceptOrgUserAsync_UserWithout2FAJoining2FARequiredOrg_ThrowsBadRequest( @@ -193,10 +140,16 @@ public async Task AcceptOrgUserAsync_WithPolicyRequirementsEnabled_UserWithout2F { SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + // Enable the PolicyRequirements feature flag for the new 2FA path sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.PolicyRequirements) .Returns(true); + // No SingleOrg policy + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); + // Organization they are trying to join requires 2FA sutProvider.GetDependency() .GetAsync(user.Id) @@ -225,15 +178,16 @@ public async Task AcceptOrgUserAsync_WithPolicyRequirementsEnabled_UserWith2FAJo { SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PolicyRequirements) - .Returns(true); - // User has 2FA enabled sutProvider.GetDependency() .TwoFactorIsEnabledAsync(user) .Returns(true); + // No SingleOrg policy + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); + // Organization they are trying to join requires 2FA sutProvider.GetDependency() .GetAsync(user.Id) @@ -262,9 +216,10 @@ public async Task AcceptOrgUserAsync_WithPolicyRequirementsEnabled_UserJoiningOr { SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PolicyRequirements) - .Returns(true); + // No SingleOrg policy + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); // Organization they are trying to join doesn't require 2FA sutProvider.GetDependency() @@ -286,6 +241,88 @@ await sutProvider.GetDependency() .ReplaceAsync(Arg.Is(ou => ou.Status == OrganizationUserStatusType.Accepted)); } + [Theory] + [BitAutoData] + public async Task AcceptOrgUserAsync_WithSingleOrgEnabled_UserJoiningOrgWithSingleOrgPolicy_ThrowsBadRequest( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + // User is part of another org + var otherOrgUser = new OrganizationUser + { + UserId = user.Id, + OrganizationId = Guid.NewGuid(), + Status = OrganizationUserStatusType.Confirmed + }; + sutProvider.GetDependency() + .GetManyByUserAsync(user.Id) + .Returns(Task.FromResult>(new List { otherOrgUser })); + + // Target org has SingleOrg policy, user is a regular User (not exempt) + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.EnabledForTargetOrganization(org.Id)); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService)); + + Assert.Equal("You cannot accept this invite until you leave or remove all other organizations.", + exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserAsync_UserInOrgWithSingleOrgPolicyAlready_ThrowsBadRequest( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, + OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + // Another org the user is in has SingleOrg policy (not the target org) + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.EnabledForAnotherOrganization()); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService)); + + Assert.Equal("You cannot accept this invite because you are in another organization which forbids it.", + exception.Message); + } + + [Theory] + [BitAutoData] + public async Task AcceptOrgUserAsync_NoSingleOrgPolicy_Succeeds( + SutProvider sutProvider, + User user, Organization org, OrganizationUser orgUser, OrganizationUserUserDetails adminUserDetails) + { + // Arrange + SetupCommonAcceptOrgUserMocks(sutProvider, user, org, orgUser, adminUserDetails); + + // No SingleOrg policy applies + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); + + // No 2FA policy either + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new RequireTwoFactorPolicyRequirement([])); + + // Act + var resultOrgUser = await sutProvider.Sut.AcceptOrgUserAsync(orgUser, user, _userService); + + // Assert + AssertValidAcceptedOrgUser(resultOrgUser, orgUser, user); + } + [Theory] [BitAutoData(OrganizationUserType.Admin)] [BitAutoData(OrganizationUserType.Owner)] @@ -871,16 +908,6 @@ private static void SetupCommonAcceptOrgUserMocks(SutProvider() - .GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg, OrganizationUserStatusType.Invited) - .Returns([]); - - // User is not part of any organization that applies the single org policy - sutProvider.GetDependency() - .AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg) - .Returns(false); - // Org does not require 2FA sutProvider.GetDependency().GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication, OrganizationUserStatusType.Invited) @@ -896,6 +923,11 @@ private static void SetupCommonAcceptOrgUserMocks(SutProvider() + .GetAsync(Arg.Any()) + .Returns(new SingleOrganizationPolicyRequirement([])); + // Auto-confirm enforcement query returns valid by default (no restrictions) var request = new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs index 50c7501ccd69..aa6e6b0c8e08 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -19,6 +19,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -140,6 +141,10 @@ public async Task ConfirmUserAsync_ToNonFree_WithExistingFreeAdminOrOwner_Succee organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); @@ -155,64 +160,68 @@ await sutProvider.GetDependency() [Theory, BitAutoData] - public async Task ConfirmUserAsync_AsUser_WithSingleOrgPolicyAppliedFromConfirmingOrg_ThrowsBadRequestException(Organization org, OrganizationUser confirmingUser, + public async Task ConfirmUserAsync_WithTwoFactorPolicyAndTwoFactorDisabled_ThrowsBadRequestException(Organization org, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, + OrganizationUser orgUserAnotherOrg, + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, string key, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); var organizationRepository = sutProvider.GetDependency(); var userRepository = sutProvider.GetDependency(); var policyService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); org.PlanType = PlanType.EnterpriseAnnually; - orgUser.Status = OrganizationUserStatusType.Accepted; orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - singleOrgPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) }); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", exception.Message); + Assert.Contains("User does not have two-step login enabled.", exception.Message); } [Theory, BitAutoData] - public async Task ConfirmUserAsync_AsUser_WithSingleOrgPolicyAppliedFromOtherOrg_ThrowsBadRequestException(Organization org, OrganizationUser confirmingUser, + public async Task ConfirmUserAsync_WithTwoFactorPolicyAndTwoFactorEnabled_Succeeds(Organization org, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - OrganizationUser orgUserAnotherOrg, [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, + [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, string key, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); var organizationRepository = sutProvider.GetDependency(); var userRepository = sutProvider.GetDependency(); var policyService = sutProvider.GetDependency(); + var twoFactorIsEnabledQuery = sutProvider.GetDependency(); org.PlanType = PlanType.EnterpriseAnnually; - orgUser.Status = OrganizationUserStatusType.Accepted; orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; + orgUser.UserId = user.Id; organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - singleOrgPolicy.OrganizationId = orgUserAnotherOrg.Id; - policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.SingleOrg).Returns(new[] { singleOrgPolicy }); + twoFactorPolicy.OrganizationId = org.Id; + policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); + twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, true) }); - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("Cannot confirm this member to the organization because they are in another organization which forbids it.", exception.Message); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); + + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); } - [Theory] - [BitAutoData(OrganizationUserType.Admin)] - [BitAutoData(OrganizationUserType.Owner)] - public async Task ConfirmUserAsync_AsOwnerOrAdmin_WithSingleOrgPolicy_ExcludedViaUserType_Success( - OrganizationUserType userType, Organization org, OrganizationUser confirmingUser, + [Theory, BitAutoData] + public async Task ConfirmUserAsync_WithSingleOrgPolicyFromConfirmingOrg_ThrowsBadRequest( + Organization org, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, OrganizationUser orgUserAnotherOrg, string key, SutProvider sutProvider) @@ -222,126 +231,105 @@ public async Task ConfirmUserAsync_AsOwnerOrAdmin_WithSingleOrgPolicy_ExcludedVi var userRepository = sutProvider.GetDependency(); org.PlanType = PlanType.EnterpriseAnnually; - orgUser.Type = userType; orgUser.Status = OrganizationUserStatusType.Accepted; orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; - orgUser.AccessSecretsManager = true; organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUser, orgUserAnotherOrg }); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); + // 2FA check passes (no 2FA policy) + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) }); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new RequireTwoFactorPolicyRequirement([])); - await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, true); - await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1)); + // Confirming org has SingleOrg policy, user is a regular User (not exempt) + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.EnabledForTargetOrganization(org.Id)); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); + Assert.Contains($"{user.Email} cannot be confirmed until they leave or remove all other organizations.", exception.Message); } [Theory, BitAutoData] - public async Task ConfirmUserAsync_WithTwoFactorPolicyAndTwoFactorDisabled_ThrowsBadRequestException(Organization org, OrganizationUser confirmingUser, + public async Task ConfirmUserAsync_WithSingleOrgPolicyFromOtherOrg_ThrowsBadRequest( + Organization org, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, OrganizationUser orgUserAnotherOrg, - [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, string key, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); var organizationRepository = sutProvider.GetDependency(); var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - var twoFactorIsEnabledQuery = sutProvider.GetDependency(); org.PlanType = PlanType.EnterpriseAnnually; + orgUser.Status = OrganizationUserStatusType.Accepted; orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; orgUser.UserId = orgUserAnotherOrg.UserId = user.Id; organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); - organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUserAnotherOrg }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUser, orgUserAnotherOrg }); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - twoFactorPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); - twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) + + // 2FA check passes (no 2FA policy) + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) }); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new RequireTwoFactorPolicyRequirement([])); + + // Other org has SingleOrg policy (not the confirming org) + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.EnabledForAnotherOrganization()); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id)); - Assert.Contains("User does not have two-step login enabled.", exception.Message); + Assert.Contains($"{user.Email} cannot be confirmed because they are in another organization which forbids it.", exception.Message); } [Theory, BitAutoData] - public async Task ConfirmUserAsync_WithTwoFactorPolicyAndTwoFactorEnabled_Succeeds(Organization org, OrganizationUser confirmingUser, + public async Task ConfirmUserAsync_NoSingleOrgPolicy_Succeeds( + Organization org, OrganizationUser confirmingUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, - [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, string key, SutProvider sutProvider) { var organizationUserRepository = sutProvider.GetDependency(); var organizationRepository = sutProvider.GetDependency(); var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - var twoFactorIsEnabledQuery = sutProvider.GetDependency(); org.PlanType = PlanType.EnterpriseAnnually; orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; orgUser.UserId = user.Id; organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUser }); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - twoFactorPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(user.Id, PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); - twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, true) }); - await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); - } + // No SingleOrg policy + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); - [Theory, BitAutoData] - public async Task ConfirmUsersAsync_WithMultipleUsers_ReturnsExpectedMixedResults(Organization org, - OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser1, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser3, - OrganizationUser anotherOrgUser, User user1, User user2, User user3, - [OrganizationUserPolicyDetails(PolicyType.TwoFactorAuthentication)] OrganizationUserPolicyDetails twoFactorPolicy, - [OrganizationUserPolicyDetails(PolicyType.SingleOrg)] OrganizationUserPolicyDetails singleOrgPolicy, - string key, SutProvider sutProvider) - { - var organizationUserRepository = sutProvider.GetDependency(); - var organizationRepository = sutProvider.GetDependency(); - var userRepository = sutProvider.GetDependency(); - var policyService = sutProvider.GetDependency(); - var twoFactorIsEnabledQuery = sutProvider.GetDependency(); + // No 2FA policy either + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new RequireTwoFactorPolicyRequirement([])); - org.PlanType = PlanType.EnterpriseAnnually; - orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = confirmingUser.OrganizationId = org.Id; - orgUser1.UserId = user1.Id; - orgUser2.UserId = user2.Id; - orgUser3.UserId = user3.Id; - anotherOrgUser.UserId = user3.Id; - var orgUsers = new[] { orgUser1, orgUser2, orgUser3 }; - organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(orgUsers); - organizationRepository.GetByIdAsync(org.Id).Returns(org); - userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user1, user2, user3 }); - twoFactorPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(Arg.Any(), PolicyType.TwoFactorAuthentication).Returns(new[] { twoFactorPolicy }); - twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id) && ids.Contains(user3.Id))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() - { - (user1.Id, true), - (user2.Id, false), - (user3.Id, true) - }); - singleOrgPolicy.OrganizationId = org.Id; - policyService.GetPoliciesApplicableToUserAsync(user3.Id, PolicyType.SingleOrg) - .Returns(new[] { singleOrgPolicy }); - organizationUserRepository.GetManyByManyUsersAsync(default) - .ReturnsForAnyArgs(new[] { orgUser1, orgUser2, orgUser3, anotherOrgUser }); + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) }); - var keys = orgUsers.ToDictionary(ou => ou.Id, _ => key); - var result = await sutProvider.Sut.ConfirmUsersAsync(confirmingUser.OrganizationId, keys, confirmingUser.Id); - Assert.Contains("", result[0].Item2); - Assert.Contains("User does not have two-step login enabled.", result[1].Item2); - Assert.Contains("Cannot confirm this member to the organization until they leave or remove all other organizations.", result[2].Item2); + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); + + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); } [Theory, BitAutoData] @@ -399,6 +387,7 @@ public async Task ConfirmUserAsync_WithPolicyRequirementsEnabled_AndTwoFactorNot orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; orgUser.UserId = user.Id; organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUser }); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); @@ -412,6 +401,8 @@ public async Task ConfirmUserAsync_WithPolicyRequirementsEnabled_AndTwoFactorNot PolicyType = PolicyType.TwoFactorAuthentication, } ])); + policyRequirementQuery.GetAsync(user.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, false) }); @@ -439,6 +430,7 @@ public async Task ConfirmUserAsync_WithPolicyRequirementsEnabled_AndTwoFactorEna orgUser.OrganizationId = confirmingUser.OrganizationId = org.Id; orgUser.UserId = user.Id; organizationUserRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); + organizationUserRepository.GetManyByManyUsersAsync(default).ReturnsForAnyArgs(new[] { orgUser }); organizationRepository.GetByIdAsync(org.Id).Returns(org); userRepository.GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); @@ -452,6 +444,8 @@ public async Task ConfirmUserAsync_WithPolicyRequirementsEnabled_AndTwoFactorEna PolicyType = PolicyType.TwoFactorAuthentication } ])); + policyRequirementQuery.GetAsync(user.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(user.Id))) .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (user.Id, true) }); @@ -490,6 +484,10 @@ public async Task ConfirmUserAsync_WithOrganizationDataOwnershipPolicyApplicable Arg.Is>(ids => ids.Contains(orgUser.UserId!.Value))) .Returns([(orgUser.UserId!.Value, new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Enabled, [policyDetails]))]); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName); await sutProvider.GetDependency() @@ -514,6 +512,10 @@ public async Task ConfirmUserAsync_WithOrganizationDataOwnershipPolicyApplicable sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, ""); await sutProvider.GetDependency() @@ -540,6 +542,10 @@ public async Task ConfirmUserAsync_WithOrganizationDataOwnershipPolicyNotApplica Arg.Is>(ids => ids.Contains(orgUser.UserId!.Value))) .Returns([(orgUser.UserId!.Value, new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, []))]); + sutProvider.GetDependency() + .GetAsync(orgUser.UserId!.Value) + .Returns(new SingleOrganizationPolicyRequirement([])); + await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName); await sutProvider.GetDependency() @@ -709,6 +715,10 @@ public async Task ConfirmUserAsync_WithAutoConfirmNotApplicable_Succeeds( .IsCompliantAsync(Arg.Any(), Arg.Any()) .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); + // Act await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); @@ -746,6 +756,10 @@ public async Task ConfirmUserAsync_WithAutoConfirmPolicyEnabled_DeletesEmergency .GetAsync(user.Id) .Returns(new AutomaticUserConfirmationPolicyRequirement([new PolicyDetails { OrganizationId = org.Id }])); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); + sutProvider.GetDependency() .IsCompliantAsync(Arg.Any(), Arg.Any()) .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); @@ -786,6 +800,10 @@ public async Task ConfirmUserAsync_WithAutoConfirmPolicyNotEnabled_DoesNotDelete .GetAsync(user.Id) .Returns(new AutomaticUserConfirmationPolicyRequirement([])); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); + sutProvider.GetDependency() .IsCompliantAsync(Arg.Any(), Arg.Any()) .Returns(Valid(new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser], user))); @@ -900,6 +918,10 @@ public async Task ConfirmUsersAsync_WithAutoConfirmEnabled_MixedResults( new AutomaticUserConfirmationPolicyEnforcementRequest(org.Id, [orgUser3, otherOrgUser], user3), new OtherOrganizationDoesNotAllowOtherMembership())); + sutProvider.GetDependency() + .GetAsync(Arg.Any()) + .Returns(new SingleOrganizationPolicyRequirement([])); + var keys = orgUsers.ToDictionary(ou => ou.Id, _ => key); // Act @@ -989,6 +1011,10 @@ public async Task ConfirmUserAsync_UseMyItemsDisabled_DoesNotCreateDefaultCollec .GetAsync(orgUser.UserId!.Value) .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Enabled, [policyDetails])); + sutProvider.GetDependency() + .GetAsync(orgUser.UserId!.Value) + .Returns(new SingleOrganizationPolicyRequirement([])); + // Act await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName); @@ -1029,6 +1055,10 @@ public async Task ConfirmUserAsync_UseMyItemsEnabled_CreatesDefaultCollection( (orgUser.UserId!.Value, new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Enabled, [policyDetails])) ]); + sutProvider.GetDependency() + .GetAsync(orgUser.UserId!.Value) + .Returns(new SingleOrganizationPolicyRequirement([])); + // Act await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName); @@ -1067,6 +1097,10 @@ public async Task ConfirmUsersAsync_UseMyItemsDisabled_DoesNotCreateDefaultColle sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser1, orgUser2 }); sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { user1, user2 }); + sutProvider.GetDependency() + .GetAsync(Arg.Any()) + .Returns(new SingleOrganizationPolicyRequirement([])); + // Act await sutProvider.Sut.ConfirmUsersAsync(organization.Id, keys, confirmingUser.Id, collectionName); @@ -1127,6 +1161,10 @@ public async Task ConfirmUsersAsync_UseMyItemsEnabled_CreatesDefaultCollections( (orgUser2.UserId!.Value, new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Enabled, [policyDetails2])) ]); + sutProvider.GetDependency() + .GetAsync(Arg.Any()) + .Returns(new SingleOrganizationPolicyRequirement([])); + // Act await sutProvider.Sut.ConfirmUsersAsync(organization.Id, keys, confirmingUser.Id, collectionName); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs index 69d6a6a228ca..37793f06eb24 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs @@ -18,6 +18,7 @@ using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -152,45 +153,6 @@ await sutProvider.GetDependency() .PushSyncOrgKeysAsync(Arg.Any()); } - [Theory, BitAutoData] - public async Task RestoreUser_WithOtherOrganizationSingleOrgPolicyEnabled_Fails( - Organization organization, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, - [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, - SutProvider sutProvider) - { - organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke - RestoreUser_Setup(organization, owner, organizationUser, sutProvider); - - sutProvider.GetDependency() - .AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) - .Returns(true); - sutProvider.GetDependency() - .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts - { - Sponsored = 0, - Users = 1 - }); - var user = new User(); - user.Email = "test@bitwarden.com"; - sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); - - Assert.Contains("test@bitwarden.com belongs to an organization that doesn't allow them to join multiple organizations", exception.Message.ToLowerInvariant()); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .RestoreAsync(Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncOrgKeysAsync(Arg.Any()); - } - [Theory, BitAutoData] public async Task RestoreUser_With2FAPolicyEnabled_WithoutUser2FAConfigured_Fails( Organization organization, @@ -270,6 +232,9 @@ public async Task RestoreUser_WithPolicyRequirementsEnabled_With2FAPolicyEnabled PolicyType = PolicyType.TwoFactorAuthentication } ])); + sutProvider.GetDependency() + .GetAsync(organizationUser.UserId.Value) + .Returns(new SingleOrganizationPolicyRequirement([])); var user = new User(); user.Email = "test@bitwarden.com"; @@ -352,6 +317,9 @@ public async Task RestoreUser_WithPolicyRequirementsEnabled_With2FAPolicyEnabled PolicyType = PolicyType.TwoFactorAuthentication } ])); + sutProvider.GetDependency() + .GetAsync(organizationUser.UserId.Value) + .Returns(new SingleOrganizationPolicyRequirement([])); await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null); @@ -364,7 +332,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RestoreUser_WithSingleOrgPolicyEnabled_Fails( + public async Task RestoreUser_WithPolicyRequirementsEnabled_WithSingleOrgPolicyEnabled_And_2FA_Policy_Fails( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, @@ -375,33 +343,43 @@ public async Task RestoreUser_WithSingleOrgPolicyEnabled_Fails( secondOrganizationUser.UserId = organizationUser.UserId; RestoreUser_Setup(organization, owner, organizationUser, sutProvider); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyRequirements) + .Returns(true); + sutProvider.GetDependency() .GetManyByUserAsync(organizationUser.UserId.Value) .Returns(new[] { organizationUser, secondOrganizationUser }); - sutProvider.GetDependency() - .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) - .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) }); + + // Mock SingleOrganizationPolicyRequirement via IPolicyRequirementQuery (new path) + sutProvider.GetDependency() + .GetAsync(organizationUser.UserId.Value) + .Returns(SingleOrganizationPolicyRequirementTestFactory.EnabledForTargetOrganization(organization.Id)); + + sutProvider.GetDependency() + .GetAsync(organizationUser.UserId.Value) + .Returns(new RequireTwoFactorPolicyRequirement( + [ + new PolicyDetails + { + OrganizationId = organizationUser.OrganizationId, + OrganizationUserStatus = OrganizationUserStatusType.Revoked, + PolicyType = PolicyType.TwoFactorAuthentication + } + ])); sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 1 }); - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) - .Returns(new[] - { - new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked } - }); - - var user = new User(); - user.Email = "test@bitwarden.com"; + var user = new User { Email = "test@bitwarden.com" }; sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); - Assert.Contains("test@bitwarden.com is not compliant with the single organization policy", exception.Message.ToLowerInvariant()); + Assert.Contains("test@bitwarden.com is not compliant with the single organization and two-step login policy", exception.Message.ToLowerInvariant()); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() @@ -415,37 +393,34 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task RestoreUser_WithSingleOrgPolicyEnabled_And_2FA_Policy_Fails( + public async Task RestoreUser_WithOtherOrgSingleOrgPolicy_Fails( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser, + OrganizationUser otherOrganizationUser, SutProvider sutProvider) { - organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke - secondOrganizationUser.UserId = organizationUser.UserId; + organizationUser.Email = null; // required to mock that user was previously confirmed RestoreUser_Setup(organization, owner, organizationUser, sutProvider); - sutProvider.GetDependency() - .GetManyByUserAsync(organizationUser.UserId.Value) - .Returns(new[] { organizationUser, secondOrganizationUser }); - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) - .Returns(new[] - { - new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked } - }); sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 1 }); - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any()) - .Returns([ - new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication, OrganizationUserStatus = OrganizationUserStatusType.Revoked } - ]); + + // Other org has SingleOrg policy (not the target org) + otherOrganizationUser.OrganizationId = Guid.NewGuid(); + otherOrganizationUser.UserId = organizationUser.UserId; + sutProvider.GetDependency() + .GetAsync(organizationUser.UserId.Value) + .Returns(SingleOrganizationPolicyRequirementTestFactory.EnabledForAnotherOrganization()); + + // No 2FA policy + sutProvider.GetDependency() + .GetAsync(organizationUser.UserId.Value) + .Returns(new RequireTwoFactorPolicyRequirement([])); var user = new User { Email = "test@bitwarden.com" }; sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); @@ -453,79 +428,56 @@ public async Task RestoreUser_WithSingleOrgPolicyEnabled_And_2FA_Policy_Fails( var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); - Assert.Contains("test@bitwarden.com is not compliant with the single organization and two-step login policy", exception.Message.ToLowerInvariant()); + Assert.Contains("test@bitwarden.com cannot be restored because they are in another organization which forbids it.", exception.Message.ToLowerInvariant()); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .RestoreAsync(Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncOrgKeysAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task RestoreUser_WithPolicyRequirementsEnabled_WithSingleOrgPolicyEnabled_And_2FA_Policy_Fails( + public async Task RestoreUse_WithSingleOrgPolicyEnabled_Fails( Organization organization, [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser secondOrganizationUser, SutProvider sutProvider) { - organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke + organizationUser.Email = null; // required to mock that user was previously confirmed secondOrganizationUser.UserId = organizationUser.UserId; RestoreUser_Setup(organization, owner, organizationUser, sutProvider); - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.PolicyRequirements) - .Returns(true); - sutProvider.GetDependency() .GetManyByUserAsync(organizationUser.UserId.Value) .Returns(new[] { organizationUser, secondOrganizationUser }); - sutProvider.GetDependency() - .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any()) - .Returns(new[] - { - new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked } - }); - sutProvider.GetDependency() - .GetAsync(organizationUser.UserId.Value) - .Returns(new RequireTwoFactorPolicyRequirement( - [ - new PolicyDetails - { - OrganizationId = organizationUser.OrganizationId, - OrganizationUserStatus = OrganizationUserStatusType.Revoked, - PolicyType = PolicyType.TwoFactorAuthentication - } - ])); sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 1 }); + + sutProvider.GetDependency() + .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value))) + .Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, true) }); + + // Target org has SingleOrg policy, user is a regular User (not exempt) + sutProvider.GetDependency() + .GetAsync(organizationUser.UserId.Value) + .Returns(SingleOrganizationPolicyRequirementTestFactory.EnabledForTargetOrganization(organization.Id)); + var user = new User { Email = "test@bitwarden.com" }; sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id, null)); - Assert.Contains("test@bitwarden.com is not compliant with the single organization and two-step login policy", exception.Message.ToLowerInvariant()); + Assert.Contains("test@bitwarden.com cannot be restored until they leave or remove all other organizations.", exception.Message.ToLowerInvariant()); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .RestoreAsync(Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .PushSyncOrgKeysAsync(Arg.Any()); } [Theory, BitAutoData] @@ -1016,6 +968,11 @@ public async Task RestoreUsers_WithPolicyRequirementsEnabled_With2FAPolicy_Block } ])); + // No SingleOrg policy + sutProvider.GetDependency() + .GetAsync(Arg.Any()) + .Returns(new SingleOrganizationPolicyRequirement([])); + // User1 has 2FA, User2 doesn't sutProvider.GetDependency() .TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(orgUser1.UserId!.Value) && ids.Contains(orgUser2.UserId!.Value))) @@ -1255,6 +1212,11 @@ private static void RestoreUser_Setup( sutProvider.GetDependency() .GetAsync(Arg.Any()) .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [])); + + // Setup default empty SingleOrganizationPolicyRequirement for any user + sutProvider.GetDependency() + .GetAsync(Arg.Any()) + .Returns(new SingleOrganizationPolicyRequirement([])); } private static void SetupOrganizationDataOwnershipPolicy( diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs index 1a05e86f2758..5869e74d150a 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs @@ -2,6 +2,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Utilities.v2.Validation; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Entities; @@ -10,6 +12,7 @@ using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Tokens; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -40,6 +43,10 @@ public async Task Init_Organization_Success(User user, Guid orgId, Guid orgUserI var organizationService = sutProvider.GetDependency(); var collectionRepository = sutProvider.GetDependency(); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); + await sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token); await organizationRepository.Received().GetByIdAsync(orgId); @@ -63,6 +70,10 @@ public async Task Init_Organization_With_CollectionName_Success(User user, Guid var organizationService = sutProvider.GetDependency(); var collectionRepository = sutProvider.GetDependency(); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); + await sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, collectionName, token); await organizationRepository.Received().GetByIdAsync(orgId); @@ -85,6 +96,10 @@ public async Task Init_Organization_When_Organization_Is_Enabled(User user, Guid var organizationRepository = sutProvider.GetDependency(); organizationRepository.GetByIdAsync(orgId).Returns(org); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token)); Assert.Equal("Organization is already enabled.", exception.Message); @@ -101,6 +116,10 @@ public async Task Init_Organization_When_Organization_Is_Not_Pending(User user, var organizationRepository = sutProvider.GetDependency(); organizationRepository.GetByIdAsync(orgId).Returns(org); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token)); Assert.Equal("Organization is not on a Pending status.", exception.Message); @@ -117,6 +136,10 @@ public async Task Init_Organization_When_Organization_Has_Public_Key(User user, var organizationRepository = sutProvider.GetDependency(); organizationRepository.GetByIdAsync(orgId).Returns(org); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token)); Assert.Equal("Organization already has a Public Key.", exception.Message); @@ -135,11 +158,67 @@ public async Task Init_Organization_When_Organization_Has_Private_Key(User user, var organizationRepository = sutProvider.GetDependency(); organizationRepository.GetByIdAsync(orgId).Returns(org); + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token)); Assert.Equal("Organization already has a Private Key.", exception.Message); } + [Theory, BitAutoData] + public async Task InitPendingOrganization_WithSingleOrgPolicy_ThrowsBadRequest( + User user, Guid orgId, Guid orgUserId, string publicKey, + string privateKey, SutProvider sutProvider, Organization org, + OrganizationUser orgUser, OrganizationUser orgUserFromAnotherOrg) + { + var token = CreateToken(orgUser, orgUserId, sutProvider); + + org.PrivateKey = null; + org.PublicKey = null; + + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(org); + + // User has SingleOrg policy from another org + orgUserFromAnotherOrg.OrganizationId = Guid.NewGuid(); + orgUserFromAnotherOrg.UserId = user.Id; + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.EnabledForAnotherOrganization()); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token)); + + Assert.Contains("You may not create an organization. You belong to an organization which has a policy that prohibits you from being a member of any other organization.", exception.Message); + } + + [Theory, BitAutoData] + public async Task InitPendingOrganization_WithoutSingleOrgPolicy_Succeeds( + User user, Guid orgId, Guid orgUserId, string publicKey, + string privateKey, SutProvider sutProvider, Organization org, OrganizationUser orgUser) + { + var token = CreateToken(orgUser, orgUserId, sutProvider); + + org.PrivateKey = null; + org.PublicKey = null; + + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(org); + + // No SingleOrg policy + sutProvider.GetDependency() + .GetAsync(user.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); + + // Act + await sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token); + + // Assert + await sutProvider.GetDependency().Received().GetByIdAsync(orgId); + await sutProvider.GetDependency().Received().UpdateAsync(org); + } + private string CreateToken(OrganizationUser orgUser, Guid orgUserId, SutProvider sutProvider) { sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs index c1fea1455e3a..2ba97223ccd0 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/OrganizationSignUp/CloudOrganizationSignUpCommandTests.cs @@ -1,5 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Services; @@ -10,6 +12,8 @@ using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -39,6 +43,10 @@ public async Task SignUp_PM_Family_Passes(PlanType planType, OrganizationSignup sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(MockPlans.Get(signup.Plan)); + sutProvider.GetDependency() + .GetAsync(signup.Owner.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); + var result = await sutProvider.Sut.SignUpOrganizationAsync(signup); await sutProvider.GetDependency().Received(1).CreateAsync( @@ -79,6 +87,10 @@ public async Task SignUp_AssignsOwnerToDefaultCollection sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(MockPlans.Get(signup.Plan)); + sutProvider.GetDependency() + .GetAsync(signup.Owner.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); + // Extract orgUserId when created Guid? orgUserId = null; await sutProvider.GetDependency() @@ -125,6 +137,10 @@ public async Task SignUp_SM_Passes(PlanType planType, OrganizationSignup signup, sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(MockPlans.Get(signup.Plan)); + sutProvider.GetDependency() + .GetAsync(signup.Owner.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); + var result = await sutProvider.Sut.SignUpOrganizationAsync(signup); await sutProvider.GetDependency().Received(1).CreateAsync( @@ -250,9 +266,74 @@ public async Task SignUpAsync_Free_ExistingFreeOrgAdmin_ThrowsBadRequest( .GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id) .Returns(1); + sutProvider.GetDependency() + .GetAsync(signup.Owner.Id) + .Returns(new SingleOrganizationPolicyRequirement([])); + // Act & Assert var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SignUpOrganizationAsync(signup)); Assert.Contains("You can only be an admin of one free organization.", exception.Message); } + + [Theory] + [BitAutoData(PlanType.EnterpriseAnnually)] + public async Task SignUpAsync_WhenSingleOrgPolicyIsEnabled_OwnerBelongsToAnotherOrgAsUser_ThrowsBadRequest( + PlanType planType, OrganizationSignup signup, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + signup.Plan = planType; + signup.AdditionalSeats = 15; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.UseSecretsManager = false; + signup.IsFromProvider = false; + + sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(MockPlans.Get(signup.Plan)); + + // User has SingleOrg policy from another org + organizationUser.UserId = signup.Owner.Id; + organizationUser.OrganizationId = Guid.NewGuid(); + sutProvider.GetDependency() + .GetAsync(signup.Owner.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.EnabledForAnotherOrganization()); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpOrganizationAsync(signup)); + Assert.Contains("You may not create an organization. You belong to an organization which has a policy that prohibits you from being a member of any other organization.", exception.Message); + } + + [Theory] + [BitAutoData(PlanType.EnterpriseAnnually)] + public async Task SignUpAsync_WithoutSingleOrgPolicy_Succeeds( + PlanType planType, OrganizationSignup signup, + SutProvider sutProvider) + { + // Arrange + signup.Plan = planType; + signup.AdditionalSeats = 15; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + signup.UseSecretsManager = false; + signup.IsFromProvider = false; + + sutProvider.GetDependency().GetPlanOrThrow(signup.Plan).Returns(MockPlans.Get(signup.Plan)); + + // No SingleOrg policy + sutProvider.GetDependency() + .GetAsync(signup.Owner.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); + + // Act + var result = await sutProvider.Sut.SignUpOrganizationAsync(signup); + + // Assert + Assert.NotNull(result.Organization); + Assert.NotNull(result.OrganizationUser); + + await sutProvider.GetDependency().Received(1).CreateAsync(Arg.Any()); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommandTests.cs index 26c092797b5f..6c90e48ba147 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/SelfHostedOrganizationSignUpCommandTests.cs @@ -1,8 +1,8 @@ using System.Security.Claims; using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; -using Bit.Core.AdminConsole.Services; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -13,6 +13,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; +using Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -144,30 +145,6 @@ public async Task SignUpAsync_WithLicenseAlreadyInUse_ThrowsBadRequestException( Assert.Contains("License is already in use", exception.Message); } - [Theory, BitAutoData] - public async Task SignUpAsync_WithSingleOrgPolicy_ThrowsBadRequestException( - User owner, string ownerKey, string collectionName, - string publicKey, string privateKey, - SutProvider sutProvider) - { - // Arrange - var globalSettings = sutProvider.GetDependency(); - var license = CreateValidOrganizationLicense(globalSettings); - - SetupCommonMocks(sutProvider, owner); - SetupLicenseValidation(sutProvider, license); - - sutProvider.GetDependency() - .AnyPoliciesApplicableToUserAsync(owner.Id, PolicyType.SingleOrg) - .Returns(true); - - // Act & Assert - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.SignUpAsync(license, owner, ownerKey, collectionName, publicKey, privateKey)); - - Assert.Contains("You may not create an organization", exception.Message); - } - [Theory, BitAutoData] public async Task SignUpAsync_WithClaimsPrincipal_UsesClaimsPrincipalToCreateOrganization( User owner, string ownerKey, string collectionName, @@ -296,6 +273,63 @@ await sutProvider.GetDependency() .DeleteOrganizationAbilityAsync(Arg.Any()); } + [Theory, BitAutoData] + public async Task SignUpAsync_WithSingleOrgPolicy_ThrowsBadRequest( + User owner, string ownerKey, string collectionName, + string publicKey, string privateKey, + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + var license = CreateValidOrganizationLicense(globalSettings); + + SetupCommonMocks(sutProvider, owner); + SetupLicenseValidation(sutProvider, license); + + // User has SingleOrg policy from another org + sutProvider.GetDependency() + .GetAsync(owner.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.EnabledForAnotherOrganization()); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpAsync(license, owner, ownerKey, collectionName, publicKey, privateKey)); + + Assert.Contains("You may not create an organization. You belong to an organization which has a policy that prohibits you from being a member of any other organization.", exception.Message); + } + + [Theory, BitAutoData] + public async Task SignUpAsync_WithoutSingleOrgPolicy_Succeeds( + User owner, string ownerKey, string collectionName, + string publicKey, string privateKey, List devices, + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + var license = CreateValidOrganizationLicense(globalSettings); + + SetupCommonMocks(sutProvider, owner); + SetupLicenseValidation(sutProvider, license); + + // No SingleOrg policy + sutProvider.GetDependency() + .GetAsync(owner.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(owner.Id) + .Returns(devices); + + // Act + var result = await sutProvider.Sut.SignUpAsync(license, owner, ownerKey, collectionName, publicKey, privateKey); + + // Assert + Assert.NotNull(result.organization); + Assert.NotNull(result.organizationUser); + + await sutProvider.GetDependency().Received(1).CreateAsync(result.organization); + } + private void SetupCommonMocks( SutProvider sutProvider, User owner) @@ -311,11 +345,11 @@ private void SetupCommonMocks( return Task.FromResult(org); }); - sutProvider.GetDependency() - .AnyPoliciesApplicableToUserAsync(owner.Id, PolicyType.SingleOrg) - .Returns(false); - globalSettings.LicenseDirectory.Returns("/tmp/licenses"); + + sutProvider.GetDependency() + .GetAsync(owner.Id) + .Returns(SingleOrganizationPolicyRequirementTestFactory.NoSinglePolicyOrganizationsForUser()); } private void SetupLicenseValidation( diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SingleOrganizationPolicyRequirementTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SingleOrganizationPolicyRequirementTests.cs new file mode 100644 index 000000000000..19ef695a3b39 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/SingleOrganizationPolicyRequirementTests.cs @@ -0,0 +1,199 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements.Errors; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +public class SingleOrganizationPolicyRequirementTests +{ + [Fact] + public void CanCreateOrganization_WithNoPolicies_ReturnsNoError() + { + var sut = new SingleOrganizationPolicyRequirement([]); + + var result = sut.CanCreateOrganization(); + + Assert.Null(result); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Accepted)] + [BitAutoData(OrganizationUserStatusType.Confirmed)] + public void CanCreateOrganization_WithAcceptedOrConfirmedUser_ReturnsError( + OrganizationUserStatusType status, Guid orgId) + { + var sut = new SingleOrganizationPolicyRequirement( + [ + new PolicyDetails + { + OrganizationId = orgId, + OrganizationUserStatus = status, + PolicyType = PolicyType.SingleOrg + } + ]); + + var result = sut.CanCreateOrganization(); + + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Invited)] + [BitAutoData(OrganizationUserStatusType.Revoked)] + public void CanCreateOrganization_WithInvitedOrRevokedUser_ReturnsNoError( + OrganizationUserStatusType status, Guid orgId) + { + var sut = new SingleOrganizationPolicyRequirement( + [ + new PolicyDetails + { + OrganizationId = orgId, + OrganizationUserStatus = status, + PolicyType = PolicyType.SingleOrg + } + ]); + + var result = sut.CanCreateOrganization(); + + Assert.Null(result); + } + + [Theory] + [BitAutoData] + public void CanJoinOrganization_NoPolicyDetails_NoOtherOrgs_ReturnsNoError(Guid targetOrgId, Guid userId) + { + var sut = new SingleOrganizationPolicyRequirement([]); + + var allOrgUsers = new List + { + new() { UserId = userId, OrganizationId = targetOrgId } + }; + + var result = sut.CanJoinOrganization(targetOrgId, allOrgUsers); + + Assert.Null(result); + } + + [Theory] + [BitAutoData] + public void CanJoinOrganization_TargetHasPolicy_UserInOtherOrg_ReturnsTargetOrgError( + Guid targetOrgId, Guid otherOrgId, Guid userId) + { + var sut = new SingleOrganizationPolicyRequirement( + [ + new PolicyDetails + { + OrganizationId = targetOrgId, + OrganizationUserStatus = OrganizationUserStatusType.Accepted, + PolicyType = PolicyType.SingleOrg + } + ]); + + var allOrgUsers = new List + { + new() { UserId = userId, OrganizationId = targetOrgId }, + new() { UserId = userId, OrganizationId = otherOrgId } + }; + + var result = sut.CanJoinOrganization(targetOrgId, allOrgUsers); + + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Accepted)] + [BitAutoData(OrganizationUserStatusType.Confirmed)] + public void CanJoinOrganization_OtherOrgHasPolicy_ReturnsOtherOrgError(OrganizationUserStatusType status, + Guid targetOrgId, Guid otherOrgId, Guid userId) + { + var sut = new SingleOrganizationPolicyRequirement( + [ + new PolicyDetails + { + OrganizationId = otherOrgId, + OrganizationUserStatus = status, + PolicyType = PolicyType.SingleOrg + } + ]); + + var allOrgUsers = new List + { + new() { UserId = userId, OrganizationId = targetOrgId } + }; + + var result = sut.CanJoinOrganization(targetOrgId, allOrgUsers); + + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Invited)] + [BitAutoData(OrganizationUserStatusType.Revoked)] + public void CanJoinOrganization_OtherOrgHasPolicy_WithInvitedOrRevokedUser_ReturnsNull( + OrganizationUserStatusType status, Guid targetOrgId, Guid otherOrgId, Guid userId) + { + var sut = new SingleOrganizationPolicyRequirement( + [ + new PolicyDetails + { + OrganizationId = otherOrgId, + OrganizationUserStatus = status, + PolicyType = PolicyType.SingleOrg + } + ]); + + var allOrgUsers = new List + { + new() { UserId = userId, OrganizationId = targetOrgId } + }; + + var result = sut.CanJoinOrganization(targetOrgId, allOrgUsers); + + Assert.Null(result); + } + + + [Theory] + [BitAutoData] + public void CanJoinOrganization_TargetHasPolicy_UserOnlyInTargetOrg_ReturnsNull( + Guid targetOrgId, Guid userId) + { + var sut = new SingleOrganizationPolicyRequirement( + [ + new PolicyDetails + { + OrganizationId = targetOrgId, + OrganizationUserStatus = OrganizationUserStatusType.Accepted, + PolicyType = PolicyType.SingleOrg + } + ]); + + var allOrgUsers = new List + { + new() { UserId = userId, OrganizationId = targetOrgId } + }; + + var result = sut.CanJoinOrganization(targetOrgId, allOrgUsers); + + Assert.Null(result); + } + + [Theory] + [BitAutoData] + public void CanJoinOrganization_EmptyOrgUsers_NoPolicies_ReturnsNull(Guid targetOrgId) + { + var sut = new SingleOrganizationPolicyRequirement([]); + + var result = sut.CanJoinOrganization(targetOrgId, new List()); + + Assert.Null(result); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirementsHelper.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirementsHelper.cs new file mode 100644 index 000000000000..f17fa0f588dc --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirementsHelper.cs @@ -0,0 +1,35 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.Enums; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies; + +public static class SingleOrganizationPolicyRequirementTestFactory +{ + public static SingleOrganizationPolicyRequirement NoSinglePolicyOrganizationsForUser() => new([]); + + public static SingleOrganizationPolicyRequirement EnabledForTargetOrganization(Guid organizationId) => + new([ + new PolicyDetails + { + OrganizationId = organizationId, + OrganizationUserId = Guid.NewGuid(), + PolicyType = PolicyType.SingleOrg, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed, + OrganizationUserType = OrganizationUserType.User + } + ]); + + public static SingleOrganizationPolicyRequirement EnabledForAnotherOrganization() => + new([ + new PolicyDetails + { + OrganizationId = Guid.NewGuid(), + OrganizationUserId = Guid.NewGuid(), + PolicyType = PolicyType.SingleOrg, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed, + OrganizationUserType = OrganizationUserType.User + } + ]); +} From 32e7ead84d3bfb9c89ecc7c7490bb8deccd8d510 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:27:33 -0400 Subject: [PATCH 38/85] Auth/PM-32487 - Emergency Access - invite or update - require min value of 1 for wait time in days. (#7168) --- .../Request/EmergencyAccessRequestModels.cs | 2 + .../EmergencyAccessControllerTests.cs | 292 ++++++++++++++++++ .../EmergencyAccessRequestModelsTests.cs | 150 +++++++++ 3 files changed, 444 insertions(+) create mode 100644 test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs create mode 100644 test/Api.Test/Auth/Models/Request/EmergencyAccessRequestModelsTests.cs diff --git a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs index 75e96ebc66b0..71e90f102acf 100644 --- a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs +++ b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs @@ -17,6 +17,7 @@ public class EmergencyAccessInviteRequestModel [Required] public EmergencyAccessType? Type { get; set; } [Required] + [Range(1, short.MaxValue)] public int WaitTimeDays { get; set; } } @@ -25,6 +26,7 @@ public class EmergencyAccessUpdateRequestModel [Required] public EmergencyAccessType Type { get; set; } [Required] + [Range(1, short.MaxValue)] public int WaitTimeDays { get; set; } public string KeyEncrypted { get; set; } diff --git a/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs b/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs new file mode 100644 index 000000000000..e5cacb3f163c --- /dev/null +++ b/test/Api.Test/Auth/Controllers/EmergencyAccessControllerTests.cs @@ -0,0 +1,292 @@ +using System.Security.Claims; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.Auth.Controllers; +using Bit.Api.Auth.Models.Response; +using Bit.Api.Models.Response; +using Bit.Api.Vault.Models.Response; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.EmergencyAccess; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Models.Data; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Auth.Controllers; + +[ControllerCustomize(typeof(EmergencyAccessController))] +[SutProviderCustomize] +public class EmergencyAccessControllerTests +{ + [Theory, BitAutoData] + public async Task GetContacts_ReturnsExpectedResult( + SutProvider sutProvider, + User user, + List details) + { + // Arrange + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetManyDetailsByGrantorIdAsync(user.Id) + .Returns(details); + + // Act + var result = await sutProvider.Sut.GetContacts(); + + // Assert + Assert.NotNull(result); + Assert.IsType>(result); + Assert.Equal(details.Count, result.Data.Count()); + } + + [Theory, BitAutoData] + public async Task GetGrantees_ReturnsExpectedResult( + SutProvider sutProvider, + User user, + List details) + { + // Arrange + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetManyDetailsByGranteeIdAsync(user.Id) + .Returns(details); + + // Act + var result = await sutProvider.Sut.GetGrantees(); + + // Assert + Assert.NotNull(result); + Assert.IsType>(result); + Assert.Equal(details.Count, result.Data.Count()); + } + + [Theory, BitAutoData] + public async Task Get_ReturnsGranteeDetailsResponseModel( + SutProvider sutProvider, + User user, + EmergencyAccessDetails details) + { + // Arrange + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetAsync(details.Id, user.Id) + .Returns(details); + + // Act + var result = await sutProvider.Sut.Get(details.Id); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task Policies_ReturnsListResponseModel( + SutProvider sutProvider, + User user, + List policies, + Guid id) + { + // Arrange + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetPoliciesAsync(id, user) + .Returns(policies); + + // Act + var result = await sutProvider.Sut.Policies(id); + + // Assert + Assert.NotNull(result); + Assert.IsType>(result); + } + + [Theory, BitAutoData] + public async Task Policies_WhenGrantorIsNotOrgOwner_ReturnsNullDataAsync( + SutProvider sutProvider, + User user, + Guid id) + { + // Arrange + // GetPoliciesAsync returns null when the grantor is not an org owner + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetPoliciesAsync(id, user) + .Returns((ICollection)null); + + // Act + var result = await sutProvider.Sut.Policies(id); + + // Assert + Assert.NotNull(result); + Assert.IsType>(result); + Assert.Null(result.Data); + } + + [Theory, BitAutoData] + public async Task Put_WithNullEmergencyAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid id, + Bit.Api.Auth.Models.Request.EmergencyAccessUpdateRequestModel model) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(id) + .Returns((EmergencyAccess)null); + + // Act & Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.Put(id, model)); + } + + [Theory, BitAutoData] + public async Task Put_WithValidEmergencyAccess_CallsSaveAsync( + SutProvider sutProvider, + User user, + EmergencyAccess emergencyAccess, + Bit.Api.Auth.Models.Request.EmergencyAccessUpdateRequestModel model) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(emergencyAccess.Id) + .Returns(emergencyAccess); + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + // Act + await sutProvider.Sut.Put(emergencyAccess.Id, model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Any(), user); + } + + [Theory, BitAutoData] + public async Task Invite_CallsInviteAsync( + SutProvider sutProvider, + User user, + Bit.Api.Auth.Models.Request.EmergencyAccessInviteRequestModel model) + { + // Arrange + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + // Act + await sutProvider.Sut.Invite(model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .InviteAsync(user, model.Email, model.Type!.Value, model.WaitTimeDays); + } + + [Theory, BitAutoData] + public async Task Takeover_ReturnsTakeoverResponseModel( + SutProvider sutProvider, + User granteeUser, + User grantorUser, + EmergencyAccess emergencyAccess, + Guid id) + { + // Arrange + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(granteeUser); + + sutProvider.GetDependency() + .TakeoverAsync(id, granteeUser) + .Returns((emergencyAccess, grantorUser)); + + // Act + var result = await sutProvider.Sut.Takeover(id); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task ViewCiphers_ReturnsViewResponseModel( + SutProvider sutProvider, + User user, + EmergencyAccessViewData viewData, + Guid id) + { + // Arrange + viewData.Ciphers = []; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .ViewAsync(id, user) + .Returns(viewData); + + // Act + var result = await sutProvider.Sut.ViewCiphers(id); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetAttachmentData_ReturnsAttachmentResponseModel( + SutProvider sutProvider, + User user, + Guid id, + Guid cipherId, + string attachmentId) + { + // Arrange + // CipherAttachment.MetaData has a circular self-reference, so construct manually + var attachmentData = new AttachmentResponseData + { + Id = attachmentId, + Url = "https://example.com/attachment", + Cipher = new Cipher(), + Data = new CipherAttachment.MetaData { FileName = "file.txt", Key = "key", Size = 1024 }, + }; + + sutProvider.GetDependency() + .GetUserByPrincipalAsync(Arg.Any()) + .Returns(user); + + sutProvider.GetDependency() + .GetAttachmentDownloadAsync(id, cipherId, attachmentId, user) + .Returns(attachmentData); + + // Act + var result = await sutProvider.Sut.GetAttachmentData(id, cipherId, attachmentId); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } +} diff --git a/test/Api.Test/Auth/Models/Request/EmergencyAccessRequestModelsTests.cs b/test/Api.Test/Auth/Models/Request/EmergencyAccessRequestModelsTests.cs new file mode 100644 index 000000000000..e57bec545567 --- /dev/null +++ b/test/Api.Test/Auth/Models/Request/EmergencyAccessRequestModelsTests.cs @@ -0,0 +1,150 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Auth.Models.Request; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Xunit; + +namespace Bit.Api.Test.Auth.Models.Request; + +public class EmergencyAccessInviteRequestModelTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public void Validate_WaitTimeDays_BelowMinimum_Invalid(int waitTimeDays) + { + var model = new EmergencyAccessInviteRequestModel + { + Email = "test@example.com", + Type = EmergencyAccessType.View, + WaitTimeDays = waitTimeDays, + }; + var result = Validate(model); + Assert.Contains(result, r => r.MemberNames.Contains("WaitTimeDays")); + } + + [Theory] + [InlineData(1)] + [InlineData(7)] + [InlineData(90)] + [InlineData(short.MaxValue)] + public void Validate_WaitTimeDays_ValidRange_Valid(int waitTimeDays) + { + var model = new EmergencyAccessInviteRequestModel + { + Email = "test@example.com", + Type = EmergencyAccessType.View, + WaitTimeDays = waitTimeDays, + }; + var result = Validate(model); + Assert.DoesNotContain(result, r => r.MemberNames.Contains("WaitTimeDays")); + } + + private static List Validate(EmergencyAccessInviteRequestModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } +} + +public class EmergencyAccessUpdateRequestModelTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public void Validate_WaitTimeDays_BelowMinimum_Invalid(int waitTimeDays) + { + var model = new EmergencyAccessUpdateRequestModel + { + Type = EmergencyAccessType.View, + WaitTimeDays = waitTimeDays, + }; + var result = Validate(model); + Assert.Contains(result, r => r.MemberNames.Contains("WaitTimeDays")); + } + + [Theory] + [InlineData(1)] + [InlineData(7)] + [InlineData(90)] + [InlineData(short.MaxValue)] + public void Validate_WaitTimeDays_ValidRange_Valid(int waitTimeDays) + { + var model = new EmergencyAccessUpdateRequestModel + { + Type = EmergencyAccessType.View, + WaitTimeDays = waitTimeDays, + }; + var result = Validate(model); + Assert.DoesNotContain(result, r => r.MemberNames.Contains("WaitTimeDays")); + } + + [Fact] + public void ToEmergencyAccess_BothKeysPresent_UpdatesKey() + { + var model = new EmergencyAccessUpdateRequestModel + { + Type = EmergencyAccessType.Takeover, + WaitTimeDays = 7, + KeyEncrypted = "new-encrypted-key", + }; + var existing = new EmergencyAccess { KeyEncrypted = "old-encrypted-key" }; + + var result = model.ToEmergencyAccess(existing); + + Assert.Equal("new-encrypted-key", result.KeyEncrypted); + } + + [Theory] + [InlineData(null, "new-encrypted-key")] + [InlineData("", "new-encrypted-key")] + [InlineData(" ", "new-encrypted-key")] + [InlineData("old-encrypted-key", null)] + [InlineData("old-encrypted-key", "")] + [InlineData("old-encrypted-key", " ")] + public void ToEmergencyAccess_EitherKeyMissingOrWhitespace_DoesNotUpdateKey( + string? existingKey, string? newKey) + { + var model = new EmergencyAccessUpdateRequestModel + { + Type = EmergencyAccessType.Takeover, + WaitTimeDays = 7, + KeyEncrypted = newKey, + }; + var existing = new EmergencyAccess { KeyEncrypted = existingKey }; + + var result = model.ToEmergencyAccess(existing); + + Assert.Equal(existingKey, result.KeyEncrypted); + } + + [Fact] + public void ToEmergencyAccess_AlwaysUpdatesTypeAndWaitTimeDays() + { + var model = new EmergencyAccessUpdateRequestModel + { + Type = EmergencyAccessType.Takeover, + WaitTimeDays = 14, + }; + var existing = new EmergencyAccess + { + Type = EmergencyAccessType.View, + WaitTimeDays = 7, + }; + + var result = model.ToEmergencyAccess(existing); + + Assert.Equal(EmergencyAccessType.Takeover, result.Type); + Assert.Equal(14, result.WaitTimeDays); + } + + private static List Validate(EmergencyAccessUpdateRequestModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } +} From f4af07db2675d6ddf8973d29e53fa5042ee44e94 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:30:51 -0400 Subject: [PATCH 39/85] Auth/PM-32821 - Finish cleaning up old registration endpoint (#7097) --- src/Api/appsettings.json | 5 -- .../Request/Accounts/RegisterRequestModel.cs | 77 ------------------- 2 files changed, 82 deletions(-) delete mode 100644 src/Identity/Models/Request/Accounts/RegisterRequestModel.cs diff --git a/src/Api/appsettings.json b/src/Api/appsettings.json index 8850c3d26912..e8c465930312 100644 --- a/src/Api/appsettings.json +++ b/src/Api/appsettings.json @@ -120,11 +120,6 @@ "Period": "1m", "Limit": 200 }, - { - "Endpoint": "post:/accounts/register", - "Period": "1m", - "Limit": 2 - }, { "Endpoint": "post:/accounts/password-hint", "Period": "60m", diff --git a/src/Identity/Models/Request/Accounts/RegisterRequestModel.cs b/src/Identity/Models/Request/Accounts/RegisterRequestModel.cs deleted file mode 100644 index 44f44977dd4f..000000000000 --- a/src/Identity/Models/Request/Accounts/RegisterRequestModel.cs +++ /dev/null @@ -1,77 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; -using System.Text.Json; -using Bit.Core; -using Bit.Core.Auth.Models.Api.Request.Accounts; -using Bit.Core.Entities; -using Bit.Core.Enums; -using Bit.Core.Utilities; - -namespace Bit.Identity.Models.Request.Accounts; - -public class RegisterRequestModel : IValidatableObject -{ - [StringLength(50)] - public string Name { get; set; } - [Required] - [StrictEmailAddress] - [StringLength(256)] - public string Email { get; set; } - [Required] - [StringLength(1000)] - public string MasterPasswordHash { get; set; } - [StringLength(50)] - public string MasterPasswordHint { get; set; } - public string Key { get; set; } - public KeysRequestModel Keys { get; set; } - public string Token { get; set; } - public Guid? OrganizationUserId { get; set; } - public KdfType? Kdf { get; set; } - public int? KdfIterations { get; set; } - public int? KdfMemory { get; set; } - public int? KdfParallelism { get; set; } - public Dictionary ReferenceData { get; set; } - - public User ToUser() - { - var user = new User - { - Name = Name, - Email = Email, - MasterPasswordHint = MasterPasswordHint, - Kdf = Kdf.GetValueOrDefault(KdfType.PBKDF2_SHA256), - KdfIterations = KdfIterations.GetValueOrDefault(AuthConstants.PBKDF2_ITERATIONS.Default), - KdfMemory = KdfMemory, - KdfParallelism = KdfParallelism - }; - - if (ReferenceData != null) - { - user.ReferenceData = JsonSerializer.Serialize(ReferenceData); - } - - if (Key != null) - { - user.Key = Key; - } - - if (Keys != null) - { - Keys.ToUser(user); - } - - return user; - } - - public IEnumerable Validate(ValidationContext validationContext) - { - if (Kdf.HasValue && KdfIterations.HasValue) - { - return KdfSettingsValidator.Validate(Kdf.Value, KdfIterations.Value, KdfMemory, KdfParallelism); - } - - return Enumerable.Empty(); - } -} From d69638f6aa2623724c447edf6234d33e001bdcf6 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:33:08 -0400 Subject: [PATCH 40/85] =?UTF-8?q?Revert=20"Revert=20"refactor(IdentityToke?= =?UTF-8?q?nResponse):=20[Auth/PM-3287]=20Remove=20deprec=E2=80=A6"=20(#71?= =?UTF-8?q?52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e6c97bd8505d3a454763e41ebfdb05c92691ad1a. --- .../RequestValidators/BaseRequestValidator.cs | 1 - .../CustomTokenRequestValidator.cs | 17 ----------------- .../Endpoints/IdentityServerTests.cs | 1 - 3 files changed, 19 deletions(-) diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index f343703665fa..28235c4a69f7 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -624,7 +624,6 @@ private async Task> BuildCustomResponse(User user, T customResponse.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); customResponse.Add("ForcePasswordReset", user.ForcePasswordReset); - customResponse.Add("ResetMasterPassword", string.IsNullOrWhiteSpace(user.MasterPassword)); customResponse.Add("Kdf", (byte)user.Kdf); customResponse.Add("KdfIterations", user.KdfIterations); customResponse.Add("KdfMemory", user.KdfMemory); diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 292cc4843824..33f4412d5197 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -4,7 +4,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.IdentityServer; -using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; @@ -154,23 +153,7 @@ protected override Task SetSuccessResult(CustomTokenRequestValidationContext con { // KeyConnectorUrl is configured in the CLI client, we just need to tell the client to use it context.Result.CustomResponse["ApiUseKeyConnector"] = true; - context.Result.CustomResponse["ResetMasterPassword"] = false; } - return Task.CompletedTask; - } - - // Key connector data should have already been set in the decryption options - // for backwards compatibility we set them this way too. We can eventually get rid of this once we clean up - // ResetMasterPassword - if (!context.Result.CustomResponse.TryGetValue("UserDecryptionOptions", out var userDecryptionOptionsObj) || - userDecryptionOptionsObj is not UserDecryptionOptions userDecryptionOptions) - { - return Task.CompletedTask; - } - - if (userDecryptionOptions is { KeyConnectorOption: { } }) - { - context.Result.CustomResponse["ResetMasterPassword"] = false; } return Task.CompletedTask; diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs index a4e6c6798e1f..102e3672c80f 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs @@ -81,7 +81,6 @@ public async Task TokenEndpoint_GrantTypePassword_Success(RegisterFinishRequestM var root = body.RootElement; AssertRefreshTokenExists(root); AssertHelper.AssertJsonProperty(root, "ForcePasswordReset", JsonValueKind.False); - AssertHelper.AssertJsonProperty(root, "ResetMasterPassword", JsonValueKind.False); var kdf = AssertHelper.AssertJsonProperty(root, "Kdf", JsonValueKind.Number).GetInt32(); Assert.Equal(0, kdf); var kdfIterations = AssertHelper.AssertJsonProperty(root, "KdfIterations", JsonValueKind.Number).GetInt32(); From 0ea6e6ff7a29eea6857630ec62f262dcab92e2cc Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:46:48 -0400 Subject: [PATCH 41/85] [PM-32424] Send Access Enumeration protection (#7166) feat: add enumeration protection to email protected sends - Implement enumeration protection for email-based protected sends - Update SendAccess validator with new protection logic - Change OTP generation failure logging from warning to error level - Remove unused constants and update validator tests --- .../SendAccess/SendAccessConstants.cs | 8 ---- .../SendEmailOtpRequestValidator.cs | 46 +++++++++---------- ...EmailOtpReqestValidatorIntegrationTests.cs | 7 +-- .../SendAccess/SendConstantsSnapshotTests.cs | 2 - .../SendEmailOtpRequestValidatorTests.cs | 18 ++++---- 5 files changed, 35 insertions(+), 46 deletions(-) diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs index f38a4a880f1d..3ed76a93d05c 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs @@ -72,14 +72,6 @@ public static class EmailOtpValidatorResults /// Represents the status indicating that both email and OTP are required, and the OTP has been sent. /// public const string EmailAndOtpRequired = "email_and_otp_required"; - /// - /// Represents the status indicating that both email and OTP are required, and the OTP is invalid. - /// - public const string EmailOtpInvalid = "otp_invalid"; - /// - /// For what ever reason the OTP was not able to be generated - /// - public const string OtpGenerationFailed = "otp_generation_failed"; } /// diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs index 02442d8c7e0b..90c41193f7c6 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs @@ -1,4 +1,5 @@ -using System.Security.Claims; +using System.Globalization; +using System.Security.Claims; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Services; @@ -9,7 +10,12 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; +/** +* The error responses here do not fully match the standard for OAuth with respect to Invalid Request vs Invalid Grant. This is intended to better protect +* against enumeration. We return Invalid Request for all errors related to the email and OTP, even if in some cases Invalid Grant might be more appropriate. +*/ public class SendEmailOtpRequestValidator( + ILogger logger, IOtpTokenProvider otpTokenProvider, IMailService mailService) : ISendAuthenticationMethodValidator { @@ -20,8 +26,7 @@ public class SendEmailOtpRequestValidator( private static readonly Dictionary _sendEmailOtpValidatorErrorDescriptions = new() { { SendAccessConstants.EmailOtpValidatorResults.EmailRequired, $"{SendAccessConstants.TokenRequest.Email} is required." }, - { SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired, $"{SendAccessConstants.TokenRequest.Email} and {SendAccessConstants.TokenRequest.Otp} are required." }, - { SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid, $"{SendAccessConstants.TokenRequest.Email} otp is invalid." }, + { SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired, $"{SendAccessConstants.TokenRequest.Email} and {SendAccessConstants.TokenRequest.Otp} are required." } }; public async Task ValidateRequestAsync(ExtensionGrantValidationContext context, EmailOtp authMethod, Guid sendId) @@ -37,21 +42,14 @@ public async Task ValidateRequestAsync(ExtensionGrantVali return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailRequired); } - /* - * This is somewhat contradictory to our process where a poor shape means invalid_request and invalid - * data is invalid_grant. - * In this case the shape is correct and the data is invalid but to protect against enumeration we treat incorrect emails - * as invalid requests. The response for a request with a correct email which needs an OTP and a request - * that has an invalid email need to be the same otherwise an attacker could enumerate until a valid email is found. - */ if (!authMethod.emails.Contains(email, StringComparer.OrdinalIgnoreCase)) { - return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired); + return BuildErrorResult(); } // get otp from request var requestOtp = request.Get(SendAccessConstants.TokenRequest.Otp); - var uniqueIdentifierForTokenCache = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); + var uniqueIdentifierForTokenCache = string.Format(CultureInfo.InvariantCulture, SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); if (string.IsNullOrEmpty(requestOtp)) { // Since the request doesn't have an OTP, generate one @@ -63,15 +61,16 @@ public async Task ValidateRequestAsync(ExtensionGrantVali // Verify that the OTP is generated if (string.IsNullOrEmpty(token)) { - return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed); + logger.LogError("Failed to generate OTP for SendAccess"); + return BuildErrorResult(); } await mailService.SendSendEmailOtpEmailAsync( email, token, - string.Format(SendAccessConstants.OtpEmail.Subject, token)); + string.Format(CultureInfo.CurrentCulture, SendAccessConstants.OtpEmail.Subject, token)); - return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired); + return BuildErrorResult(); } // validate request otp @@ -84,13 +83,18 @@ await mailService.SendSendEmailOtpEmailAsync( // If OTP is invalid return error result if (!otpResult) { - return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid); + return BuildErrorResult(); } return BuildSuccessResult(sendId, email!); } - private static GrantValidationResult BuildErrorResult(string error) + /// + /// Build the error response for the SendEmailOtpRequestValidator. + /// + /// The error code to use for the validation result. This is defaulted to EmailAndOtpRequired if not specified because it is the most common response. + /// A GrantValidationResult representing the error. + private static GrantValidationResult BuildErrorResult(string error = SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired) { switch (error) { @@ -102,14 +106,6 @@ private static GrantValidationResult BuildErrorResult(string error) { { SendAccessConstants.SendAccessError, error } }); - case SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid: - return new GrantValidationResult( - TokenRequestErrors.InvalidGrant, - errorDescription: _sendEmailOtpValidatorErrorDescriptions[error], - new Dictionary - { - { SendAccessConstants.SendAccessError, error } - }); default: return new GrantValidationResult( TokenRequestErrors.InvalidRequest, diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs index 9250859de119..63d8829bc341 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs @@ -2,6 +2,7 @@ using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.IntegrationTestCommon.Factories; using Duende.IdentityModel; using NSubstitute; @@ -132,7 +133,7 @@ public async Task SendAccess_EmailOtpProtectedSend_ValidOtp_ReturnsAccessToken() } [Fact] - public async Task SendAccess_EmailOtpProtectedSend_InvalidOtp_ReturnsInvalidGrant() + public async Task SendAccess_EmailOtpProtectedSend_InvalidOtp_ReturnsInvalidRequest() { // Arrange var sendId = Guid.NewGuid(); @@ -170,8 +171,8 @@ public async Task SendAccess_EmailOtpProtectedSend_InvalidOtp_ReturnsInvalidGran // Assert var content = await response.Content.ReadAsStringAsync(); - Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content); - Assert.Contains("email otp is invalid", content); + Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content); + Assert.Contains($"{SendAccessConstants.TokenRequest.Email} and {SendAccessConstants.TokenRequest.Otp} are required.", content); } [Fact] diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs index 76a40410c9e8..41458f22dc9f 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs @@ -50,8 +50,6 @@ public void EmailOtpValidatorResults_Constants_HaveCorrectValues() // Assert Assert.Equal("email_required", SendAccessConstants.EmailOtpValidatorResults.EmailRequired); Assert.Equal("email_and_otp_required", SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired); - Assert.Equal("otp_invalid", SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid); - Assert.Equal("otp_generation_failed", SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed); } [Fact] diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs index 5001ac88da31..7b5f1fc45132 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.Auth.Identity; +using System.Globalization; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; @@ -96,7 +97,7 @@ public async Task ValidateRequestAsync_EmailWithoutOtp_GeneratesAndSendsOtp( Request = tokenRequest }; - var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); + var expectedUniqueId = string.Format(CultureInfo.InvariantCulture, SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); sutProvider.GetDependency>() .GenerateTokenAsync( @@ -181,7 +182,7 @@ public async Task ValidateRequestAsync_ValidOtp_ReturnsSuccess( emailOtp = emailOtp with { emails = [email] }; - var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); + var expectedUniqueId = string.Format(CultureInfo.InvariantCulture, SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); sutProvider.GetDependency>() .ValidateTokenAsync( @@ -216,7 +217,7 @@ await sutProvider.GetDependency() } [Theory, BitAutoData] - public async Task ValidateRequestAsync_InvalidOtp_ReturnsInvalidGrant( + public async Task ValidateRequestAsync_InvalidOtp_ReturnsInvalidRequest( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, EmailOtp emailOtp, @@ -233,7 +234,7 @@ public async Task ValidateRequestAsync_InvalidOtp_ReturnsInvalidGrant( emailOtp = emailOtp with { emails = [email] }; - var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); + var expectedUniqueId = string.Format(CultureInfo.InvariantCulture, SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); sutProvider.GetDependency>() .ValidateTokenAsync(invalidOtp, @@ -247,8 +248,8 @@ public async Task ValidateRequestAsync_InvalidOtp_ReturnsInvalidGrant( // Assert Assert.True(result.IsError); - Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); - Assert.Equal("email otp is invalid.", result.ErrorDescription); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal($"{SendAccessConstants.TokenRequest.Email} and {SendAccessConstants.TokenRequest.Otp} are required.", result.ErrorDescription); // Verify OTP validation was attempted await sutProvider.GetDependency>() @@ -265,8 +266,9 @@ public void Constructor_WithValidParameters_CreatesInstance() // Arrange var otpTokenProvider = Substitute.For>(); var mailService = Substitute.For(); + var logger = Substitute.For>(); // Act - var validator = new SendEmailOtpRequestValidator(otpTokenProvider, mailService); + var validator = new SendEmailOtpRequestValidator(logger, otpTokenProvider, mailService); // Assert Assert.NotNull(validator); From 8b527a01b51c6a27e7a0d3c53863d39a10ea2b92 Mon Sep 17 00:00:00 2001 From: Samuel Warfield Date: Mon, 9 Mar 2026 11:06:57 -0600 Subject: [PATCH 42/85] [PM-27864] Add PQC TLS Support (#6547) * Add PQC TLS Support * Update util/Setup/NginxConfigBuilder.cs Co-authored-by: Addison Beck * Update util/Setup/NginxConfigBuilder.cs Co-authored-by: Addison Beck * Update util/Setup/NginxConfigBuilder.cs Co-authored-by: Addison Beck * Update util/Setup/NginxConfigBuilder.cs Co-authored-by: Addison Beck * Update util/Setup/Templates/NginxConfig.hbs Co-authored-by: Vince Grassia <593223+vgrassia@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Vince Grassia <593223+vgrassia@users.noreply.github.com> --------- Co-authored-by: Addison Beck Co-authored-by: Vince Grassia <593223+vgrassia@users.noreply.github.com> --- util/Setup/Configuration.cs | 6 ++++++ util/Setup/NginxConfigBuilder.cs | 25 ++++++++++++++++++++----- util/Setup/Templates/NginxConfig.hbs | 3 +++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/util/Setup/Configuration.cs b/util/Setup/Configuration.cs index d5d0139496db..c62f36490b5c 100644 --- a/util/Setup/Configuration.cs +++ b/util/Setup/Configuration.cs @@ -45,6 +45,12 @@ public class Configuration "Learn more: https://wiki.mozilla.org/Security/Server_Side_TLS")] public string SslCiphersuites { get; set; } + [Description("SSL curves (groups in TLS 1.3) used by Nginx (ssl_ecdh_curve). Leave empty for recommended default.\n" + + "Similar to the cipher list, this is a colon separated list of human readable names or NIDs.\n" + + "NID list: https://boringssl.googlesource.com/boringssl/+/refs/heads/master/include/openssl/nid.h\n" + + "Learn more: https://wiki.mozilla.org/Security/Server_Side_TLS")] + public string SslCurves { get; set; } + [Description("Installation uses a managed Let's Encrypt certificate.")] public bool SslManagedLetsEncrypt { get; set; } diff --git a/util/Setup/NginxConfigBuilder.cs b/util/Setup/NginxConfigBuilder.cs index ecaa4280b3a6..44de5c87815f 100644 --- a/util/Setup/NginxConfigBuilder.cs +++ b/util/Setup/NginxConfigBuilder.cs @@ -113,10 +113,12 @@ public TemplateModel(Context context) } else { - SslCiphers = "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" + - "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:" + - "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:" + - "ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256"; + // Cipher list is Mozilla's "intermediate" list, See: + // https://mozilla.github.io/server-side-tls/ssl-config-generator/ + SslCiphers = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" + + "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:" + + "ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:" + + "DHE-RSA-CHACHA20-POLY1305;"; } if (!string.IsNullOrWhiteSpace(context.Config.SslVersions)) @@ -125,7 +127,19 @@ public TemplateModel(Context context) } else { - SslProtocols = "TLSv1.2"; + // Also based on Mozilla's Intermediate list + SslProtocols = "TLSv1.2 TLSv1.3"; + } + if (!string.IsNullOrWhiteSpace(context.Config.SslCurves)) + { + SslCurves = context.Config.SslCurves; + } + else + { + // Also based on Mozilla's Intermediate list with one addition, the X25519MLKEM768 curve + // for post quantum cryptography, X25519MLKEM768 has been adopted by most browsers at this + // time. See https://blog.cloudflare.com/pq-2025/ for an in depth explanation. + SslCurves = "X25519:X25519MLKEM768:prime256v1:secp384r1"; } } @@ -140,6 +154,7 @@ public TemplateModel(Context context) public string DiffieHellmanPath { get; set; } public string SslCiphers { get; set; } public string SslProtocols { get; set; } + public string SslCurves { get; set; } public string ContentSecurityPolicy { get; set; } public List RealIps { get; set; } } diff --git a/util/Setup/Templates/NginxConfig.hbs b/util/Setup/Templates/NginxConfig.hbs index f37987ca7051..2319e82402ac 100644 --- a/util/Setup/Templates/NginxConfig.hbs +++ b/util/Setup/Templates/NginxConfig.hbs @@ -32,6 +32,9 @@ server { ssl_protocols {{{SslProtocols}}}; ssl_ciphers "{{{SslCiphers}}}"; +{{#if SslCurves}} + ssl_ecdh_curve {{{SslCurves}}}; +{{/if}} # Enables server-side protection from BEAST attacks ssl_prefer_server_ciphers on; {{#if CaPath}} From ded06cd22ab16076bcb180291462c200bb3c89de Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Mon, 9 Mar 2026 13:27:15 -0400 Subject: [PATCH 43/85] [PM-33061] Tax Id Should Be Added When Upgrading to Teams or Enterprise (#7131) * refactor(billing): change billing address request type * feat(billing): add tax id support for international business plans * feat(billing): add billing address tax id handling * test: add tests for tax id handling during upgrade * fix(billing): run dotnet format * fix(billing): remove extra line * fix(billing): modify return type of HandleAsync * test(billing): update tests to reflect updated command signature * fix(billing): run dotnet format * tests(billing): fix tests * test(billing): format --- .../UpgradePremiumToOrganizationRequest.cs | 2 +- .../UpgradePremiumToOrganizationCommand.cs | 38 +++- ...pgradePremiumToOrganizationRequestTests.cs | 53 ++++- ...pgradePremiumToOrganizationCommandTests.cs | 196 ++++++++++++++++++ 4 files changed, 283 insertions(+), 6 deletions(-) diff --git a/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs b/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs index 62a5c2adff56..112fa69010b2 100644 --- a/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs +++ b/src/Api/Billing/Models/Requests/Premium/UpgradePremiumToOrganizationRequest.cs @@ -26,7 +26,7 @@ public class UpgradePremiumToOrganizationRequest public required ProductTierType TargetProductTierType { get; set; } [Required] - public required MinimalBillingAddressRequest BillingAddress { get; set; } + public required CheckoutBillingAddressRequest BillingAddress { get; set; } private PlanType PlanType { diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index ffb7993c75e3..a7bf3e20cea5 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -2,6 +2,7 @@ using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -12,6 +13,8 @@ using Bit.Core.Utilities; using Microsoft.Extensions.Logging; using Stripe; +using CountryAbbreviations = Bit.Core.Constants.CountryAbbreviations; +using TaxExempt = Bit.Core.Billing.Constants.StripeConstants.TaxExempt; namespace Bit.Core.Billing.Premium.Commands; /// @@ -40,7 +43,7 @@ Task> Run( string encryptedPrivateKey, string? collectionName, PlanType targetPlanType, - Payment.Models.BillingAddress billingAddress); + BillingAddress billingAddress); } public class UpgradePremiumToOrganizationCommand( @@ -65,7 +68,7 @@ public Task> Run( string encryptedPrivateKey, string? collectionName, PlanType targetPlanType, - Payment.Models.BillingAddress billingAddress) => HandleAsync(async () => + BillingAddress billingAddress) => HandleAsync(async () => { // Validate that the user has an active Premium subscription if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" }) @@ -198,9 +201,16 @@ public Task> Run( { Country = billingAddress.Country, PostalCode = billingAddress.PostalCode - } + }, + TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None }); + // Add tax ID to customer for accurate tax calculation if provided + if (billingAddress.TaxId != null) + { + await AddTaxIdToCustomerAsync(user, billingAddress.TaxId); + } + // Update the subscription in Stripe await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions); @@ -271,4 +281,26 @@ await organizationApiKeyRepository.CreateAsync(new OrganizationApiKey return organization.Id; }); + + /// + /// Adds a tax ID to the Stripe customer for accurate tax calculation. + /// If the tax ID is a Spanish NIF, also adds the corresponding EU VAT ID. + /// + /// The user whose Stripe customer will be updated with the tax ID. + /// The tax ID to add, including the type and value. + private async Task AddTaxIdToCustomerAsync(User user, TaxID taxId) + { + await stripeAdapter.CreateTaxIdAsync(user.GatewayCustomerId, + new TaxIdCreateOptions { Type = taxId.Code, Value = taxId.Value }); + + if (taxId.Code == StripeConstants.TaxIdType.SpanishNIF) + { + await stripeAdapter.CreateTaxIdAsync(user.GatewayCustomerId, + new TaxIdCreateOptions + { + Type = StripeConstants.TaxIdType.EUVAT, + Value = $"ES{taxId.Value}" + }); + } + } } diff --git a/test/Api.Test/Billing/Models/Requests/UpgradePremiumToOrganizationRequestTests.cs b/test/Api.Test/Billing/Models/Requests/UpgradePremiumToOrganizationRequestTests.cs index b9cb7754d8aa..d43084cdf710 100644 --- a/test/Api.Test/Billing/Models/Requests/UpgradePremiumToOrganizationRequestTests.cs +++ b/test/Api.Test/Billing/Models/Requests/UpgradePremiumToOrganizationRequestTests.cs @@ -22,7 +22,7 @@ public void ToDomain_ValidTierTypes_ReturnsPlanType(ProductTierType tierType, Pl EncryptedPrivateKey = "encrypted-private-key", CollectionName = "Default Collection", TargetProductTierType = tierType, - BillingAddress = new MinimalBillingAddressRequest + BillingAddress = new CheckoutBillingAddressRequest { Country = "US", PostalCode = "12345" @@ -56,7 +56,7 @@ public void ToDomain_InvalidTierTypes_ThrowsInvalidOperationException(ProductTie PublicKey = "public-key", EncryptedPrivateKey = "encrypted-private-key", TargetProductTierType = tierType, - BillingAddress = new MinimalBillingAddressRequest + BillingAddress = new CheckoutBillingAddressRequest { Country = "US", PostalCode = "12345" @@ -67,4 +67,53 @@ public void ToDomain_InvalidTierTypes_ThrowsInvalidOperationException(ProductTie var exception = Assert.Throws(() => sut.ToDomain()); Assert.Contains($"Cannot upgrade Premium subscription to {tierType} plan", exception.Message); } + + [Theory] + [InlineData(ProductTierType.Teams, PlanType.TeamsAnnually, "DE", "10115", "eu_vat", "DE123456789")] + [InlineData(ProductTierType.Enterprise, PlanType.EnterpriseAnnually, "FR", "75001", "eu_vat", "FR12345678901")] + public void ToDomain_BusinessPlansWithNonUsTaxId_IncludesTaxIdInBillingAddress( + ProductTierType tierType, + PlanType expectedPlanType, + string country, + string postalCode, + string taxIdCode, + string taxIdValue) + { + // Arrange + var sut = new UpgradePremiumToOrganizationRequest + { + OrganizationName = "International Business", + Key = "encrypted-key", + TargetProductTierType = tierType, + PublicKey = "public-key", + EncryptedPrivateKey = "encrypted-private-key", + CollectionName = "Default Collection", + BillingAddress = new CheckoutBillingAddressRequest + { + Country = country, + PostalCode = postalCode, + TaxId = new CheckoutBillingAddressRequest.TaxIdRequest + { + Code = taxIdCode, + Value = taxIdValue + } + } + }; + + // Act + var (organizationName, key, publicKey, encryptedPrivateKey, collectionName, planType, billingAddress) = sut.ToDomain(); + + // Assert + Assert.Equal("International Business", organizationName); + Assert.Equal("encrypted-key", key); + Assert.Equal("public-key", publicKey); + Assert.Equal("encrypted-private-key", encryptedPrivateKey); + Assert.Equal("Default Collection", collectionName); + Assert.Equal(expectedPlanType, planType); + Assert.Equal(country, billingAddress.Country); + Assert.Equal(postalCode, billingAddress.PostalCode); + Assert.NotNull(billingAddress.TaxId); + Assert.Equal(taxIdCode, billingAddress.TaxId.Code); + Assert.Equal(taxIdValue, billingAddress.TaxId.Value); + } } diff --git a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs index 158f08f048c7..181a5e6d33e5 100644 --- a/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/UpgradePremiumToOrganizationCommandTests.cs @@ -1202,4 +1202,200 @@ await _collectionRepository.Received(1).CreateAsync( Arg.Any>(), Arg.Any>()); } + + [Theory, BitAutoData] + public async Task Run_WithNoTaxId_SetsTaxExemptToNone_DoesNotCreateTaxId(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + var billingAddress = new Core.Billing.Payment.Models.BillingAddress + { + Country = "US", + PostalCode = "12345", + TaxId = null + }; + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", "Default Collection", PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + await _stripeAdapter.Received(1).UpdateCustomerAsync( + "cus_123", + Arg.Is(options => + options.TaxExempt == StripeConstants.TaxExempt.None)); + await _stripeAdapter.DidNotReceive().CreateTaxIdAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Run_WithTaxId_SetsTaxExemptToReverse_CreatesOneTaxId(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _stripeAdapter.CreateTaxIdAsync(Arg.Any(), Arg.Any()).Returns(new TaxId()); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + var billingAddress = new Core.Billing.Payment.Models.BillingAddress + { + Country = "DE", + PostalCode = "10115", + TaxId = new Core.Billing.Payment.Models.TaxID("eu_vat", "DE123456789") + }; + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", "Default Collection", PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + await _stripeAdapter.Received(1).UpdateCustomerAsync( + "cus_123", + Arg.Is(options => + options.TaxExempt == StripeConstants.TaxExempt.Reverse)); + await _stripeAdapter.Received(1).CreateTaxIdAsync( + "cus_123", + Arg.Is(options => + options.Type == "eu_vat" && + options.Value == "DE123456789")); + } + + [Theory, BitAutoData] + public async Task Run_WithSpanishNIF_SetsTaxExemptToReverse_CreatesBothSpanishNIFAndEUVAT(User user) + { + // Arrange + user.Premium = true; + user.GatewaySubscriptionId = "sub_123"; + user.GatewayCustomerId = "cus_123"; + + var mockSubscription = new Subscription + { + Id = "sub_123", + Items = new StripeList + { + Data = new List + { + new SubscriptionItem + { + Id = "si_premium", + Price = new Price { Id = "premium-annually" } + } + } + }, + Metadata = new Dictionary() + }; + + var mockPremiumPlans = CreateTestPremiumPlansList(); + var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually"); + + _stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription); + _pricingClient.ListPremiumPlans().Returns(mockPremiumPlans); + _pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan); + _stripeAdapter.UpdateSubscriptionAsync(Arg.Any(), Arg.Any()).Returns(mockSubscription); + _stripeAdapter.UpdateCustomerAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new Customer())); + _stripeAdapter.CreateTaxIdAsync(Arg.Any(), Arg.Any()).Returns(new TaxId()); + _organizationRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationApiKeyRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _organizationUserRepository.CreateAsync(Arg.Any()).Returns(callInfo => Task.FromResult(callInfo.Arg())); + _applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any()).Returns(Task.CompletedTask); + _userService.SaveUserAsync(user).Returns(Task.CompletedTask); + + var billingAddress = new Core.Billing.Payment.Models.BillingAddress + { + Country = "ES", + PostalCode = "28001", + TaxId = new Core.Billing.Payment.Models.TaxID(StripeConstants.TaxIdType.SpanishNIF, "A12345678") + }; + + // Act + var result = await _command.Run(user, "My Organization", "encrypted-key", "public-key", "encrypted-private-key", "Default Collection", PlanType.TeamsAnnually, billingAddress); + + // Assert + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).UpdateCustomerAsync( + "cus_123", + Arg.Is(options => + options.TaxExempt == StripeConstants.TaxExempt.Reverse)); + + // Verify Spanish NIF was created + await _stripeAdapter.Received(1).CreateTaxIdAsync( + "cus_123", + Arg.Is(options => + options.Type == StripeConstants.TaxIdType.SpanishNIF && + options.Value == "A12345678")); + + // Verify EU VAT was created with ES prefix + await _stripeAdapter.Received(1).CreateTaxIdAsync( + "cus_123", + Arg.Is(options => + options.Type == StripeConstants.TaxIdType.EUVAT && + options.Value == "ESA12345678")); + + + } } From 679de603197c66a71f856d19979f7804ce0e40cc Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:37:51 -0500 Subject: [PATCH 44/85] [PM-32581] Refactor organization subscription update process (#7132) * chore: add CLAUDE.local.md and .worktrees to gitignore * feat(billing): add Stripe interval and payment behavior constants and feature flag * feat(billing): add OrganizationSubscriptionChangeSet model and unit tests * refactor(billing): rename UpdateOrganizationSubscriptionCommand to BulkUpdateOrganizationSubscriptionsCommand * feat(billing): add UpdateOrganizationSubscriptionCommand with tests * feat(billing): use UpdateOrganizationSubscriptionCommand in BulkUpdateOrganizationSubscriptions behind feature flag * feat(billing): use UpdateOrganizationSubscriptionCommand in SetUpSponsorshipCommand behind feature flag * feat(billing): add UpgradeOrganizationPlanVNextCommand with tests and feature flag gate * feat(billing): use UpdateOrganizationSubscriptionCommand in OrganizationService.AdjustSeatsAsync behind feature flag * feat(billing): use UpdateOrganizationSubscriptionCommand in UpdateSecretsManagerSubscriptionCommand behind feature flag * feat(billing): use UpdateOrganizationSubscriptionCommand in BillingHelpers.AdjustStorageAsync behind feature flag * chore: run dotnet format * fix(billing): missed optional owner in OrganizationBillingService.Finalize after merge * refactor(billing): address PR feedback on UpdateOrganizationSubscription --- .gitignore | 5 +- .../Jobs/OrganizationSubscriptionUpdateJob.cs | 4 +- ...kUpdateOrganizationSubscriptionsCommand.cs | 72 ++ ...UpdateOrganizationSubscriptionsCommand.cs} | 4 +- .../UpdateOrganizationSubscriptionCommand.cs | 43 - .../Implementations/OrganizationService.cs | 31 +- .../Billing/Commands/BillingCommandResult.cs | 8 + src/Core/Billing/Constants/StripeConstants.cs | 7 + .../Extensions/ServiceCollectionExtensions.cs | 2 + .../UpdateOrganizationSubscriptionCommand.cs | 245 +++++ .../UpgradeOrganizationPlanVNextCommand.cs | 184 ++++ .../OrganizationSubscriptionChangeSet.cs | 132 +++ src/Core/Billing/Services/IStripeAdapter.cs | 6 +- .../Services/Implementations/StripeAdapter.cs | 6 +- .../Implementations/StripePaymentService.cs | 7 + src/Core/Constants.cs | 1 + .../Cloud/SetUpSponsorshipCommand.cs | 42 +- ...SubscriptionServiceCollectionExtensions.cs | 2 +- ...UpdateSecretsManagerSubscriptionCommand.cs | 63 +- .../UpgradeOrganizationPlanCommand.cs | 20 + .../Services/Implementations/UserService.cs | 9 +- src/Core/Utilities/BillingHelpers.cs | 38 +- .../OrganizationSubscriptionUpdateJobTests.cs | 8 +- ...teOrganizationSubscriptionsCommandTests.cs | 280 ++++++ ...ateOrganizationSubscriptionCommandTests.cs | 146 --- .../Services/OrganizationServiceTests.cs | 170 ++++ ...ateOrganizationSubscriptionCommandTests.cs | 910 ++++++++++++++++++ ...pgradeOrganizationPlanVNextCommandTests.cs | 373 +++++++ .../OrganizationSubscriptionChangeSetTests.cs | 254 +++++ .../Cloud/SetUpSponsorshipCommandTests.cs | 91 ++ ...eSecretsManagerSubscriptionCommandTests.cs | 170 ++++ .../UpgradeOrganizationPlanCommandTests.cs | 77 ++ test/Core.Test/Services/UserServiceTests.cs | 47 +- 33 files changed, 3228 insertions(+), 229 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/BulkUpdateOrganizationSubscriptionsCommand.cs rename src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/{IUpdateOrganizationSubscriptionCommand.cs => IBulkUpdateOrganizationSubscriptionsCommand.cs} (77%) delete mode 100644 src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs create mode 100644 src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs create mode 100644 src/Core/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommand.cs create mode 100644 src/Core/Billing/Organizations/Models/OrganizationSubscriptionChangeSet.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/BulkUpdateOrganizationSubscriptionsCommandTests.cs delete mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommandTests.cs create mode 100644 test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommandTests.cs create mode 100644 test/Core.Test/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommandTests.cs create mode 100644 test/Core.Test/Billing/Organizations/Models/OrganizationSubscriptionChangeSetTests.cs diff --git a/.gitignore b/.gitignore index db8cb50f84d6..7b6eb2d43318 100644 --- a/.gitignore +++ b/.gitignore @@ -234,7 +234,10 @@ bitwarden_license/src/Sso/Sso.zip /identity.json /api.json /api.public.json -.serena/ # Serena .serena/ + +# Claude Code +CLAUDE.local.md +.worktrees/ diff --git a/src/Api/AdminConsole/Jobs/OrganizationSubscriptionUpdateJob.cs b/src/Api/AdminConsole/Jobs/OrganizationSubscriptionUpdateJob.cs index 3a6dbb22f467..172dd4894e35 100644 --- a/src/Api/AdminConsole/Jobs/OrganizationSubscriptionUpdateJob.cs +++ b/src/Api/AdminConsole/Jobs/OrganizationSubscriptionUpdateJob.cs @@ -9,7 +9,7 @@ namespace Bit.Api.AdminConsole.Jobs; public class OrganizationSubscriptionUpdateJob(ILogger logger, IGetOrganizationSubscriptionsToUpdateQuery query, - IUpdateOrganizationSubscriptionCommand command, + IBulkUpdateOrganizationSubscriptionsCommand command, IFeatureService featureService) : BaseJob(logger) { protected override async Task ExecuteJobAsync(IJobExecutionContext _) @@ -28,7 +28,7 @@ protected override async Task ExecuteJobAsync(IJobExecutionContext _) logger.LogInformation("OrganizationSubscriptionUpdateJob - {numberOfOrganizations} organization(s) to update", organizationSubscriptionsToUpdate.Count); - await command.UpdateOrganizationSubscriptionAsync(organizationSubscriptionsToUpdate); + await command.BulkUpdateOrganizationSubscriptionsAsync(organizationSubscriptionsToUpdate); logger.LogInformation("OrganizationSubscriptionUpdateJob - COMPLETED"); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/BulkUpdateOrganizationSubscriptionsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/BulkUpdateOrganizationSubscriptionsCommand.cs new file mode 100644 index 000000000000..29748913aa0d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/BulkUpdateOrganizationSubscriptionsCommand.cs @@ -0,0 +1,72 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using OrganizationSubscriptionUpdate = Bit.Core.AdminConsole.Models.Data.Organizations.OrganizationSubscriptionUpdate; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; + +public class BulkUpdateOrganizationSubscriptionsCommand( + IStripePaymentService paymentService, + IOrganizationRepository repository, + TimeProvider timeProvider, + ILogger logger, + IFeatureService featureService, + IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand) : IBulkUpdateOrganizationSubscriptionsCommand +{ + public async Task BulkUpdateOrganizationSubscriptionsAsync(IEnumerable subscriptionsToUpdate) + { + var successfulSyncs = new List(); + var useUpdateOrganizationSubscriptionCommand = + featureService.IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand); + + foreach (var subscriptionUpdate in subscriptionsToUpdate) + { + if (useUpdateOrganizationSubscriptionCommand) + { + var changeSet = OrganizationSubscriptionChangeSet.UpdatePasswordManagerSeats( + subscriptionUpdate.Plan!, + subscriptionUpdate.Seats); + + var result = + await updateOrganizationSubscriptionCommand.Run(subscriptionUpdate.Organization, changeSet); + + if (result.Success) + { + successfulSyncs.Add(subscriptionUpdate.Organization.Id); + } + else + { + logger.LogError("Failed to update organization {OrganizationId} subscription.", subscriptionUpdate.Organization.Id); + } + } + else + { + try + { + await paymentService.AdjustSeatsAsync(subscriptionUpdate.Organization, + subscriptionUpdate.Plan, + subscriptionUpdate.Seats); + + successfulSyncs.Add(subscriptionUpdate.Organization.Id); + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to update organization {organizationId} subscription.", + subscriptionUpdate.Organization.Id); + } + } + } + + if (successfulSyncs.Count == 0) + { + return; + } + + await repository.UpdateSuccessfulOrganizationSyncStatusAsync(successfulSyncs, timeProvider.GetUtcNow().UtcDateTime); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IUpdateOrganizationSubscriptionCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IBulkUpdateOrganizationSubscriptionsCommand.cs similarity index 77% rename from src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IUpdateOrganizationSubscriptionCommand.cs rename to src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IBulkUpdateOrganizationSubscriptionsCommand.cs index c8f5a15d3986..71d22dced183 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IUpdateOrganizationSubscriptionCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/Interfaces/IBulkUpdateOrganizationSubscriptionsCommand.cs @@ -2,7 +2,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; -public interface IUpdateOrganizationSubscriptionCommand +public interface IBulkUpdateOrganizationSubscriptionsCommand { /// /// Attempts to update the subscription of all organizations that have had a subscription update. @@ -12,5 +12,5 @@ public interface IUpdateOrganizationSubscriptionCommand /// In the event of a failure, it will log the failure and maybe be picked up in later runs. /// /// The collection of organization subscriptions to update. - Task UpdateOrganizationSubscriptionAsync(IEnumerable subscriptionsToUpdate); + Task BulkUpdateOrganizationSubscriptionsAsync(IEnumerable subscriptionsToUpdate); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs deleted file mode 100644 index e4d5a94c4cd4..000000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommand.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Bit.Core.AdminConsole.Models.Data.Organizations; -using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; -using Bit.Core.Billing.Services; -using Bit.Core.Repositories; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations; - -public class UpdateOrganizationSubscriptionCommand(IStripePaymentService paymentService, - IOrganizationRepository repository, - TimeProvider timeProvider, - ILogger logger) : IUpdateOrganizationSubscriptionCommand -{ - public async Task UpdateOrganizationSubscriptionAsync(IEnumerable subscriptionsToUpdate) - { - var successfulSyncs = new List(); - - foreach (var subscriptionUpdate in subscriptionsToUpdate) - { - try - { - await paymentService.AdjustSeatsAsync(subscriptionUpdate.Organization, - subscriptionUpdate.Plan, - subscriptionUpdate.Seats); - - successfulSyncs.Add(subscriptionUpdate.Organization.Id); - } - catch (Exception ex) - { - logger.LogError(ex, - "Failed to update organization {organizationId} subscription.", - subscriptionUpdate.Organization.Id); - } - } - - if (successfulSyncs.Count == 0) - { - return; - } - - await repository.UpdateSuccessfulOrganizationSyncStatusAsync(successfulSyncs, timeProvider.GetUtcNow().UtcDateTime); - } -} diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 109128b11458..358bbe68cc1e 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -19,6 +19,8 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -65,6 +67,7 @@ public class OrganizationService : IOrganizationService private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand; private readonly IStripeAdapter _stripeAdapter; + private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand; public OrganizationService( IOrganizationRepository organizationRepository, @@ -91,8 +94,7 @@ public OrganizationService( IPricingClient pricingClient, IPolicyRequirementQuery policyRequirementQuery, ISendOrganizationInvitesCommand sendOrganizationInvitesCommand, - IStripeAdapter stripeAdapter - ) + IStripeAdapter stripeAdapter, IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -119,6 +121,7 @@ IStripeAdapter stripeAdapter _policyRequirementQuery = policyRequirementQuery; _sendOrganizationInvitesCommand = sendOrganizationInvitesCommand; _stripeAdapter = stripeAdapter; + _updateOrganizationSubscriptionCommand = updateOrganizationSubscriptionCommand; } public async Task ReinstateSubscriptionAsync(Guid organizationId) @@ -147,8 +150,14 @@ public async Task AdjustStorageAsync(Guid organizationId, short storageA throw new BadRequestException("Plan does not allow additional storage."); } - var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, organization, storageAdjustmentGb, - plan.PasswordManager.StripeStoragePlanId, plan.PasswordManager.BaseStorageGb); + var secret = await BillingHelpers.AdjustStorageAsync( + _paymentService, + _updateOrganizationSubscriptionCommand, + _featureService, + organization, + storageAdjustmentGb, + plan.PasswordManager.StripeStoragePlanId, + plan.PasswordManager.BaseStorageGb); await ReplaceAndUpdateCacheAsync(organization); return secret; } @@ -295,7 +304,19 @@ private async Task AdjustSeatsAsync(Organization organization, int seatA _logger.LogInformation("{Method}: Invoking _paymentService.AdjustSeatsAsync with {AdditionalSeats} additional seats for Organization ({OrganizationID})", nameof(AdjustSeatsAsync), additionalSeats, organization.Id); - var paymentIntentClientSecret = await _paymentService.AdjustSeatsAsync(organization, plan, additionalSeats); + string paymentIntentClientSecret = null; + + if (_featureService.IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand)) + { + var changeSet = OrganizationSubscriptionChangeSet.UpdatePasswordManagerSeats(plan, additionalSeats); + var result = await _updateOrganizationSubscriptionCommand.Run(organization, changeSet); + result.GetValueOrThrow(); + } + else + { + paymentIntentClientSecret = await _paymentService.AdjustSeatsAsync(organization, plan, additionalSeats); + } + organization.Seats = (short?)newSeatTotal; _logger.LogInformation("{Method}: Invoking _organizationRepository.ReplaceAsync with {Seats} seats for Organization ({OrganizationID})", nameof(AdjustSeatsAsync), organization.Seats, organization.Id); ; diff --git a/src/Core/Billing/Commands/BillingCommandResult.cs b/src/Core/Billing/Commands/BillingCommandResult.cs index db260e70382c..9a63e559f448 100644 --- a/src/Core/Billing/Commands/BillingCommandResult.cs +++ b/src/Core/Billing/Commands/BillingCommandResult.cs @@ -27,6 +27,8 @@ public class BillingCommandResult(OneOf i public static implicit operator BillingCommandResult(Conflict conflict) => new(conflict); public static implicit operator BillingCommandResult(Unhandled unhandled) => new(unhandled); + public bool Success => IsT0; + public BillingCommandResult Map(Func f) => Match( value => new BillingCommandResult(f(value)), @@ -39,6 +41,12 @@ public Task TapAsync(Func f) => Match( _ => Task.CompletedTask, _ => Task.CompletedTask, _ => Task.CompletedTask); + + public T GetValueOrThrow() => Match( + value => value, + badRequest => throw new BillingException(badRequest.Response), + conflict => throw new BillingException(message: conflict.Response), + unhandled => throw new BillingException(message: unhandled.Response, innerException: unhandled.Exception)); } public static class BillingCommandResultExtensions diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 7d7072e448cb..a3e314198fb9 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -65,6 +65,12 @@ public static string[] InputErrors() => ]; } + public static class Intervals + { + public const string Month = "month"; + public const string Year = "year"; + } + public static class InvoiceStatus { public const string Draft = "draft"; @@ -89,6 +95,7 @@ public static class MetadataKeys public static class PaymentBehavior { public const string DefaultIncomplete = "default_incomplete"; + public const string PendingIfIncomplete = "pending_if_incomplete"; } public static class PaymentMethodTypes diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index e619ba39119c..d7146455900c 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -42,6 +42,8 @@ public static void AddBillingOperations(this IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); } private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services) diff --git a/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs b/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs new file mode 100644 index 000000000000..befed75db4a9 --- /dev/null +++ b/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs @@ -0,0 +1,245 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; +using Microsoft.Extensions.Logging; +using OneOf; +using Stripe; + +namespace Bit.Core.Billing.Organizations.Commands; + +using static Core.Constants; +using static StripeConstants; + +/// +/// Updates an organization's Stripe subscription based on a set of changes described by an +/// . Handles adding, removing, and updating +/// subscription items as well as proration, invoice finalization, and tax exemption reconciliation. +/// +public interface IUpdateOrganizationSubscriptionCommand +{ + /// + /// Applies the provided to the organization's Stripe subscription. + /// + /// The organization whose subscription will be updated. + /// The set of changes to apply to the subscription. + /// + /// A containing the updated + /// on success, or an error result if validation or the Stripe operation fails. + /// + Task> Run( + Organization organization, + OrganizationSubscriptionChangeSet changeSet); +} + +public class UpdateOrganizationSubscriptionCommand( + ILogger logger, + IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IUpdateOrganizationSubscriptionCommand +{ + private static readonly List _validSubscriptionStatusesForUpdate = + [ + SubscriptionStatus.Trialing, SubscriptionStatus.Active, SubscriptionStatus.PastDue + ]; + + private readonly ILogger _logger = logger; + + protected override Conflict DefaultConflict => + new("We had a problem updating your subscription. Please contact support for assistance."); + + public Task> Run( + Organization organization, + OrganizationSubscriptionChangeSet changeSet) => HandleAsync(async () => + { + var subscription = await FetchSubscriptionAsync(organization); + + if (subscription is null) + { + return new BadRequest("We couldn't find your subscription."); + } + + if (!_validSubscriptionStatusesForUpdate.Contains(subscription.Status)) + { + _logger.LogWarning( + "{Command}: Tried to update organization ({OrganizationId}) subscription ({SubscriptionId}) with status ({SubscriptionStatus})", + CommandName, organization.Id, subscription.Id, subscription.Status); + return new BadRequest("Your subscription cannot be updated in its current status."); + } + + if (changeSet.Changes.Count == 0) + { + _logger.LogWarning( + "{Command}: Change set for organization ({OrganizationId}) subscription ({SubscriptionId}) contained zero changes", + CommandName, organization.Id, subscription.Id); + return new Conflict("No changes were provided for the organization subscription update"); + } + + await ReconcileTaxExemptionAsync(subscription.Customer); + + var hasStructuralChanges = changeSet.Changes.Any(change => change.IsStructural); + var isChargedAutomatically = subscription.CollectionMethod == CollectionMethod.ChargeAutomatically; + var isBilledAnnually = subscription.Items.FirstOrDefault()?.Price.Recurring?.Interval == Intervals.Year; + + var prorationBehavior = + hasStructuralChanges ? ProrationBehavior.AlwaysInvoice : ProrationBehavior.CreateProrations; + var paymentBehavior = + hasStructuralChanges && isChargedAutomatically ? PaymentBehavior.PendingIfIncomplete : null; + + var items = new List(); + foreach (var change in changeSet.Changes) + { + var validationResult = change.Match( + addItem => ValidateItemAddition(addItem, subscription), + changeItemPrice => ValidateItemPriceChange(changeItemPrice, subscription), + removeItem => ValidateItemRemoval(removeItem, subscription), + updateItemQuantity => ValidateItemQuantityUpdate(updateItemQuantity, subscription)); + + if (validationResult.IsT1) + { + return validationResult.AsT1; + } + + items.Add(validationResult.AsT0); + } + + var options = new SubscriptionUpdateOptions { Items = items, ProrationBehavior = prorationBehavior }; + + if (paymentBehavior is not null) + { + options.PaymentBehavior = paymentBehavior; + } + + if (isBilledAnnually && !hasStructuralChanges && subscription.Status != SubscriptionStatus.Trialing) + { + options.PendingInvoiceItemInterval = new SubscriptionPendingInvoiceItemIntervalOptions + { + Interval = Intervals.Month + }; + } + + var updatedSubscription = await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options); + + // ReSharper disable once InvertIf + if (!isChargedAutomatically && hasStructuralChanges && updatedSubscription.LatestInvoiceId is not null) + { + var invoice = await stripeAdapter.GetInvoiceAsync(updatedSubscription.LatestInvoiceId); + + if (invoice is { Status: InvoiceStatus.Draft }) + { + var finalizedInvoice = await stripeAdapter.FinalizeInvoiceAsync(invoice.Id, + new InvoiceFinalizeOptions { AutoAdvance = false }); + + await stripeAdapter.SendInvoiceAsync(finalizedInvoice.Id); + } + else + { + _logger.LogWarning( + "{Command}: Latest invoice ({InvoiceId}) after subscription ({SubscriptionId}) update for organization ({OrganizationId}) was in '{Status}' status", + CommandName, invoice.Id, subscription.Id, organization.Id, invoice.Status); + } + } + + return updatedSubscription; + }); + + private async Task FetchSubscriptionAsync(Organization organization) + { + try + { + return await stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions + { + Expand = ["customer"] + }); + } + catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.ResourceMissing) + { + _logger.LogError("{Command}: Subscription ({SubscriptionId}) for Organization ({OrganizationId}) was not found", + CommandName, organization.GatewaySubscriptionId, organization.Id); + return null; + } + } + + private async Task ReconcileTaxExemptionAsync(Customer customer) + { + if (customer is + { + Address.Country: not CountryAbbreviations.UnitedStates, + TaxExempt: not TaxExempt.Reverse + }) + { + await stripeAdapter.UpdateCustomerAsync(customer.Id, + new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse }); + } + } + + private static OneOf ValidateItemAddition( + AddItem addItem, Subscription subscription) + { + var duplicate = subscription.Items.Data + .FirstOrDefault(i => i.Price.Id == addItem.PriceId); + + if (duplicate is not null) + { + return new BadRequest($"Subscription already contains an item with price '{addItem.PriceId}'."); + } + + return new SubscriptionItemOptions + { + Price = addItem.PriceId, + Quantity = addItem.Quantity + }; + } + + private static OneOf ValidateItemPriceChange( + ChangeItemPrice priceChange, Subscription subscription) + { + var currentItem = subscription.Items.Data + .FirstOrDefault(i => i.Price.Id == priceChange.CurrentPriceId); + + if (currentItem is null) + { + return new BadRequest($"Subscription does not contain an item with price '{priceChange.CurrentPriceId}'."); + } + + return new SubscriptionItemOptions + { + Id = currentItem.Id, + Price = priceChange.UpdatedPriceId, + Quantity = priceChange.Quantity ?? currentItem.Quantity + }; + } + + private static OneOf ValidateItemQuantityUpdate( + UpdateItemQuantity updateItemQuantity, Subscription subscription) + { + var existingItem = subscription.Items.Data + .FirstOrDefault(i => i.Price.Id == updateItemQuantity.PriceId); + + if (existingItem is null) + { + return new BadRequest($"Subscription does not contain an item with price '{updateItemQuantity.PriceId}'."); + } + + return updateItemQuantity.Quantity == 0 + ? new SubscriptionItemOptions { Id = existingItem.Id, Deleted = true } + : new SubscriptionItemOptions { Id = existingItem.Id, Price = updateItemQuantity.PriceId, Quantity = updateItemQuantity.Quantity }; + } + + private static OneOf ValidateItemRemoval( + RemoveItem removeItem, Subscription subscription) + { + var existingItem = subscription.Items.Data + .FirstOrDefault(i => i.Price.Id == removeItem.PriceId); + + if (existingItem is null) + { + return new BadRequest($"Subscription does not contain an item with price '{removeItem.PriceId}'."); + } + + return new SubscriptionItemOptions + { + Id = existingItem.Id, + Deleted = true + }; + } +} diff --git a/src/Core/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommand.cs b/src/Core/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommand.cs new file mode 100644 index 000000000000..950fb8012dc1 --- /dev/null +++ b/src/Core/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommand.cs @@ -0,0 +1,184 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Services; +using Bit.Core.Billing.Pricing; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Models.Business; +using Bit.Core.Models.StaticStore; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using OneOf.Types; + +namespace Bit.Core.Billing.Organizations.Commands; + +/// +/// Upgrades an organization's subscription plan by updating its Stripe subscription +/// and persisting the corresponding feature and configuration changes to the database. +/// +public interface IUpgradeOrganizationPlanVNextCommand +{ + /// + /// Upgrades the to the specified + /// by applying subscription changes through + /// and updating the organization's features, limits, and encryption keys. + /// + /// The organization to upgrade. + /// The target plan to upgrade to. + /// Optional public key encryption key pair data to set during the upgrade. + /// + /// A containing on success, + /// or an error result if the subscription update or feature persistence fails. + /// + Task> Run( + Organization organization, + Plan plan, + PublicKeyEncryptionKeyPairData? keys); +} + +public class UpgradeOrganizationPlanVNextCommand( + ILogger logger, + IOrganizationBillingService organizationBillingService, + IOrganizationService organizationService, + IPricingClient pricingClient, + IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand) : BaseBillingCommand(logger), IUpgradeOrganizationPlanVNextCommand +{ + protected override Conflict DefaultConflict => new("We had a problem upgrading your plan. Please contact support for assistance."); + + public Task> Run( + Organization organization, + Plan plan, + PublicKeyEncryptionKeyPairData? keys) => HandleAsync(async () => + { + var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + if (currentPlan.UpgradeSortOrder == plan.UpgradeSortOrder) + { + return new BadRequest("Your organization is already on this plan."); + } + + if (currentPlan.UpgradeSortOrder > plan.UpgradeSortOrder) + { + return new BadRequest("You can't downgrade your organization's plan."); + } + + if (string.IsNullOrEmpty(organization.GatewayCustomerId)) + { + return new Conflict($"Organization's ({organization.Id}) Stripe customer should already have been created"); + } + + // Upgrade from Free + if (currentPlan.Type == PlanType.Free && organization is + { + GatewaySubscriptionId: null, + Seats: not null + }) + { + var sale = OrganizationSale.From(organization, new OrganizationUpgrade + { + Plan = plan.Type, + AdditionalSeats = organization.Seats ?? 0, + UseSecretsManager = organization.UseSecretsManager, + AdditionalSmSeats = organization.UseSecretsManager ? organization.SmSeats : null, + }, null); + + await organizationBillingService.Finalize(sale); + + if (plan.HasNonSeatBasedPasswordManagerPlan()) + { + organization.Seats = plan.PasswordManager.BaseSeats; + } + + organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb; + + if (organization.UseSecretsManager) + { + organization.SmServiceAccounts = plan.SecretsManager.BaseServiceAccount; + } + + await UpdateOrganizationFeaturesAsync(organization, plan, keys); + + return new None(); + } + + var builder = OrganizationSubscriptionChangeSet.Builder(); + + builder.ChangeItemPrice( + GetPasswordManagerPriceId(currentPlan), + GetPasswordManagerPriceId(plan)); + + if (organization.MaxStorageGb > currentPlan.PasswordManager.BaseStorageGb) + { + builder.ChangeItemPrice( + currentPlan.PasswordManager.StripeStoragePlanId, + plan.PasswordManager.StripeStoragePlanId); + } + + if (organization.UseSecretsManager) + { + builder.ChangeItemPrice( + currentPlan.SecretsManager.StripeSeatPlanId, + plan.SecretsManager.StripeSeatPlanId); + + if (organization.SmServiceAccounts > currentPlan.SecretsManager.BaseServiceAccount) + { + builder.ChangeItemPrice( + currentPlan.SecretsManager.StripeServiceAccountPlanId, + plan.SecretsManager.StripeServiceAccountPlanId); + } + } + + var changeSet = builder.Build(); + var result = await updateOrganizationSubscriptionCommand.Run(organization, changeSet); + + if (!result.Success) + { + return result.Map(_ => new None()); + } + + await UpdateOrganizationFeaturesAsync(organization, plan, keys); + + return result.Map(_ => new None()); + }); + + private static string GetPasswordManagerPriceId(Plan plan) => + plan.HasNonSeatBasedPasswordManagerPlan() + ? plan.PasswordManager.StripePlanId + : plan.PasswordManager.StripeSeatPlanId; + + private async Task UpdateOrganizationFeaturesAsync( + Organization organization, + Plan plan, + PublicKeyEncryptionKeyPairData? keys) + { + organization.Plan = plan.Name; + organization.PlanType = plan.Type; + organization.MaxCollections = plan.PasswordManager.MaxCollections; + organization.UsePolicies = plan.HasPolicies; + organization.UseSso = plan.HasSso; + organization.UseKeyConnector = plan.HasKeyConnector; + organization.UseScim = plan.HasScim; + organization.UseGroups = plan.HasGroups; + organization.UseDirectory = plan.HasDirectory; + organization.UseEvents = plan.HasEvents; + organization.UseTotp = plan.HasTotp; + organization.Use2fa = plan.Has2fa; + organization.UseApi = plan.HasApi; + organization.UseResetPassword = plan.HasResetPassword; + organization.SelfHost = plan.HasSelfHost; + organization.UsersGetPremium = plan.UsersGetPremium; + organization.UseCustomPermissions = plan.HasCustomPermissions; + organization.UseOrganizationDomains = plan.HasOrganizationDomains; + organization.UseAutomaticUserConfirmation = plan.AutomaticUserConfirmation; + organization.UseMyItems = plan.HasMyItems; + + if (keys != null) + { + organization.BackfillPublicPrivateKeys(keys); + } + + await organizationService.ReplaceAndUpdateCacheAsync(organization); + } +} diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionChangeSet.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionChangeSet.cs new file mode 100644 index 000000000000..8c5bc9614938 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionChangeSet.cs @@ -0,0 +1,132 @@ +using Bit.Core.Models.StaticStore; +using OneOf; + +namespace Bit.Core.Billing.Organizations.Models; + +/// +/// Adds a new line item to the subscription. +/// +public record AddItem(string PriceId, int Quantity); + +/// +/// Replaces an existing line item's price (e.g. switching from monthly to annual billing). +/// Optionally updates the quantity; if null, the current quantity is preserved. +/// +public record ChangeItemPrice(string CurrentPriceId, string UpdatedPriceId, int? Quantity); + +/// +/// Removes a line item from the subscription. +/// +public record RemoveItem(string PriceId); + +/// +/// Updates the quantity of an existing line item. Setting quantity to 0 deletes the item. +/// +public record UpdateItemQuantity(string PriceId, int Quantity); + +/// +/// A union type representing a single change to apply to an organization's Stripe subscription. +/// A change is considered "structural" (triggering immediate invoicing) if it adds, removes, +/// or re-prices a line item, or sets a quantity to 0. Non-structural quantity updates use prorations. +/// +public class OrganizationSubscriptionChange(OneOf input) + : OneOfBase(input) +{ + public static implicit operator OrganizationSubscriptionChange(AddItem addItem) => + new(addItem); + + public static implicit operator OrganizationSubscriptionChange(ChangeItemPrice changeItemPrice) => + new(changeItemPrice); + + public static implicit operator OrganizationSubscriptionChange(RemoveItem removeItem) => + new(removeItem); + + public static implicit operator OrganizationSubscriptionChange(UpdateItemQuantity updateItemQuantity) => + new(updateItemQuantity); + + public bool IsItemAddition => IsT0; + public bool IsItemPriceChange => IsT1; + public bool IsItemRemoval => IsT2; + public bool IsItemQuantityUpdate => IsT3; + public bool IsStructural => !IsItemQuantityUpdate || AsT3.Quantity == 0; +} + +/// +/// A collection of items to apply atomically to +/// an organization's Stripe subscription. Use the static factory methods for common single-change +/// operations, or for composing multiple changes. +/// +public record OrganizationSubscriptionChangeSet +{ + public required IReadOnlyList Changes { get; init; } = []; + + public static OrganizationSubscriptionChangeSet UpdatePasswordManagerSeats(Plan plan, int seats) => + new() + { + Changes = + [ + new UpdateItemQuantity(plan.PasswordManager.StripeSeatPlanId, seats) + ] + }; + + public static OrganizationSubscriptionChangeSet UpdateStorage(Plan plan, int storage) => + new() + { + Changes = + [ + new UpdateItemQuantity(plan.PasswordManager.StripeStoragePlanId, storage) + ] + }; + + public static OrganizationSubscriptionChangeSet UpdateSecretsManagerSeats(Plan plan, int seats) => + new() + { + Changes = + [ + new UpdateItemQuantity(plan.SecretsManager.StripeSeatPlanId, seats) + ] + }; + + public static OrganizationSubscriptionChangeSet UpdateSecretsManagerServiceAccounts(Plan plan, int serviceAccounts) => + new() + { + Changes = + [ + new UpdateItemQuantity(plan.SecretsManager.StripeServiceAccountPlanId, serviceAccounts) + ] + }; + + public static OrganizationSubscriptionChangeSetBuilder Builder() => new(); +} + +public class OrganizationSubscriptionChangeSetBuilder +{ + private readonly List _changes = []; + + public OrganizationSubscriptionChangeSetBuilder AddItem(string priceId, int quantity) + { + _changes.Add(new AddItem(priceId, quantity)); + return this; + } + + public OrganizationSubscriptionChangeSetBuilder ChangeItemPrice(string currentPriceId, string updatedPriceId, int? quantity = null) + { + _changes.Add(new ChangeItemPrice(currentPriceId, updatedPriceId, quantity)); + return this; + } + + public OrganizationSubscriptionChangeSetBuilder RemoveItem(string priceId) + { + _changes.Add(new RemoveItem(priceId)); + return this; + } + + public OrganizationSubscriptionChangeSetBuilder UpdateItemQuantity(string priceId, int quantity) + { + _changes.Add(new UpdateItemQuantity(priceId, quantity)); + return this; + } + + public OrganizationSubscriptionChangeSet Build() => + new() { Changes = _changes.AsReadOnly() }; +} diff --git a/src/Core/Billing/Services/IStripeAdapter.cs b/src/Core/Billing/Services/IStripeAdapter.cs index 017dd166e0c1..d7d14432caf9 100644 --- a/src/Core/Billing/Services/IStripeAdapter.cs +++ b/src/Core/Billing/Services/IStripeAdapter.cs @@ -22,14 +22,14 @@ Task CreateCustomerBalanceTransactionAsync(string cu Task DeleteCustomerDiscountAsync(string customerId, CustomerDeleteDiscountOptions options = null); Task UpdateSubscriptionAsync(string id, SubscriptionUpdateOptions options = null); Task CancelSubscriptionAsync(string id, SubscriptionCancelOptions options = null); - Task GetInvoiceAsync(string id, InvoiceGetOptions options); + Task GetInvoiceAsync(string id, InvoiceGetOptions options = null); Task> ListInvoicesAsync(StripeInvoiceListOptions options); Task CreateInvoiceAsync(InvoiceCreateOptions options); Task CreateInvoicePreviewAsync(InvoiceCreatePreviewOptions options); Task> SearchInvoiceAsync(InvoiceSearchOptions options); Task UpdateInvoiceAsync(string id, InvoiceUpdateOptions options); - Task FinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options); - Task SendInvoiceAsync(string id, InvoiceSendOptions options); + Task FinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options = null); + Task SendInvoiceAsync(string id, InvoiceSendOptions options = null); Task PayInvoiceAsync(string id, InvoicePayOptions options = null); Task DeleteInvoiceAsync(string id, InvoiceDeleteOptions options = null); Task VoidInvoiceAsync(string id, InvoiceVoidOptions options = null); diff --git a/src/Core/Billing/Services/Implementations/StripeAdapter.cs b/src/Core/Billing/Services/Implementations/StripeAdapter.cs index 10c781083668..5672c6ca4d0f 100644 --- a/src/Core/Billing/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Billing/Services/Implementations/StripeAdapter.cs @@ -98,7 +98,7 @@ public Task CancelSubscriptionAsync(string id, SubscriptionCancelO /************* ** INVOICE ** *************/ - public Task GetInvoiceAsync(string id, InvoiceGetOptions options) => + public Task GetInvoiceAsync(string id, InvoiceGetOptions options = null) => _invoiceService.GetAsync(id, options); public async Task> ListInvoicesAsync(StripeInvoiceListOptions options) @@ -132,10 +132,10 @@ public async Task> SearchInvoiceAsync(InvoiceSearchOptions options public Task UpdateInvoiceAsync(string id, InvoiceUpdateOptions options) => _invoiceService.UpdateAsync(id, options); - public Task FinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options) => + public Task FinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options = null) => _invoiceService.FinalizeInvoiceAsync(id, options); - public Task SendInvoiceAsync(string id, InvoiceSendOptions options) => + public Task SendInvoiceAsync(string id, InvoiceSendOptions options = null) => _invoiceService.SendInvoiceAsync(id, options); public Task PayInvoiceAsync(string id, InvoicePayOptions options = null) => diff --git a/src/Core/Billing/Services/Implementations/StripePaymentService.cs b/src/Core/Billing/Services/Implementations/StripePaymentService.cs index ffc18aa74865..30eac78698c2 100644 --- a/src/Core/Billing/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Billing/Services/Implementations/StripePaymentService.cs @@ -50,6 +50,7 @@ public StripePaymentService( _pricingClient = pricingClient; } + // TODO: Remove with FF: pm-32581-use-update-organization-subscription-command -> Updated SetUpSponsorshipCommand private async Task ChangeOrganizationSponsorship( Organization org, OrganizationSponsorship sponsorship, @@ -73,9 +74,11 @@ private async Task ChangeOrganizationSponsorship( } } + // TODO: Remove with FF: pm-32581-use-update-organization-subscription-command -> Updated SetUpSponsorshipCommand public Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship) => ChangeOrganizationSponsorship(org, sponsorship, true); + // TODO: Remove -> Unused public Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship) => ChangeOrganizationSponsorship(org, sponsorship, false); @@ -227,6 +230,7 @@ await _stripeAdapter.UpdateSubscriptionAsync(sub.Id, return paymentIntentClientSecret; } + // TODO: Remove with FF: pm-32581-use-update-organization-subscription-command -> Updated UpgradeOrganizationPlanCommand public async Task AdjustSubscription( Organization organization, StaticStore.Plan updatedPlan, @@ -254,14 +258,17 @@ public async Task AdjustSubscription( }), true); } + // TODO: Remove with FF: pm-32581-use-update-organization-subscription-command -> Updated OrganizationService.AdjustSeatsAsync public Task AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats) => FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats)); + // TODO: Remove with FF: pm-32581-use-update-organization-subscription-command -> Updated UpdateSecretsManagerSubscriptionCommand public Task AdjustSmSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats) => FinalizeSubscriptionChangeAsync( organization, new SmSeatSubscriptionUpdate(organization, plan, additionalSeats)); + // TODO: Remove with FF: pm-32581-use-update-organization-subscription-command -> Updated UpdateSecretsManagerSubscriptionCommand public Task AdjustServiceAccountsAsync( Organization organization, StaticStore.Plan plan, diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8baa4e1fc464..2578249df1a8 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -191,6 +191,7 @@ public static class FeatureFlagKeys public const string PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page"; public const string PM29108_EnablePersonalDiscounts = "pm-29108-enable-personal-discounts"; public const string PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade"; + public const string PM32581_UseUpdateOrganizationSubscriptionCommand = "pm-32581-use-update-organization-subscription-command"; /* Key Management Team */ public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration"; diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs index 6d60f05b2a9e..402e372764bc 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommand.cs @@ -1,11 +1,15 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; +using Bit.Core.Services; namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Cloud; @@ -14,12 +18,24 @@ public class SetUpSponsorshipCommand : ISetUpSponsorshipCommand private readonly IOrganizationSponsorshipRepository _organizationSponsorshipRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IStripePaymentService _paymentService; + private readonly IFeatureService _featureService; + private readonly IPricingClient _pricingClient; + private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand; - public SetUpSponsorshipCommand(IOrganizationSponsorshipRepository organizationSponsorshipRepository, IOrganizationRepository organizationRepository, IStripePaymentService paymentService) + public SetUpSponsorshipCommand( + IOrganizationSponsorshipRepository organizationSponsorshipRepository, + IOrganizationRepository organizationRepository, + IStripePaymentService paymentService, + IFeatureService featureService, + IPricingClient pricingClient, + IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand) { _organizationSponsorshipRepository = organizationSponsorshipRepository; _organizationRepository = organizationRepository; _paymentService = paymentService; + _featureService = featureService; + _pricingClient = pricingClient; + _updateOrganizationSubscriptionCommand = updateOrganizationSubscriptionCommand; } public async Task SetUpSponsorshipAsync(OrganizationSponsorship sponsorship, @@ -50,7 +66,8 @@ public async Task SetUpSponsorshipAsync(OrganizationSponsorship sponsorship, } // Check org to sponsor's product type - var requiredSponsoredProductType = SponsoredPlans.Get(sponsorship.PlanSponsorshipType.Value).SponsoredProductTierType; + var sponsoredPlan = SponsoredPlans.Get(sponsorship.PlanSponsorshipType.Value); + var requiredSponsoredProductType = sponsoredPlan.SponsoredProductTierType; var sponsoredOrganizationProductTier = sponsoredOrganization.PlanType.GetProductTier(); if (sponsoredOrganizationProductTier != requiredSponsoredProductType) @@ -58,9 +75,26 @@ public async Task SetUpSponsorshipAsync(OrganizationSponsorship sponsorship, throw new BadRequestException("Can only redeem sponsorship offer on families organizations."); } - await _paymentService.SponsorOrganizationAsync(sponsoredOrganization, sponsorship); - await _organizationRepository.UpsertAsync(sponsoredOrganization); + if (_featureService.IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand)) + { + var existingPlan = await _pricingClient.GetPlanOrThrow(sponsoredOrganization.PlanType); + var changeSet = OrganizationSubscriptionChangeSet.Builder() + .RemoveItem(existingPlan.PasswordManager.StripePlanId) + .AddItem(sponsoredPlan.StripePlanId, 1) + .Build(); + var result = await _updateOrganizationSubscriptionCommand.Run(sponsoredOrganization, changeSet); + var updatedSubscription = result.GetValueOrThrow(); + var currentPeriodEnd = updatedSubscription.GetCurrentPeriodEnd(); + sponsoredOrganization.ExpirationDate = currentPeriodEnd; + sponsorship.ValidUntil = currentPeriodEnd; + } + else + { + await _paymentService.SponsorOrganizationAsync(sponsoredOrganization, sponsorship); + } + + await _organizationRepository.UpsertAsync(sponsoredOrganization); sponsorship.SponsoredOrganizationId = sponsoredOrganization.Id; sponsorship.OfferedToEmail = null; await _organizationSponsorshipRepository.UpsertAsync(sponsorship); diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/OrganizationSubscriptionServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/OrganizationSubscriptionServiceCollectionExtensions.cs index ef12e1d0f180..5885919a38cb 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/OrganizationSubscriptionServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/OrganizationSubscriptionServiceCollectionExtensions.cs @@ -13,6 +13,6 @@ public static void AddOrganizationSubscriptionServices(this IServiceCollection s .AddScoped() .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped(); } } diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs index baf2616a53af..b878474c76f7 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs @@ -3,6 +3,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -27,6 +29,8 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs private readonly IOrganizationRepository _organizationRepository; private readonly IApplicationCacheService _applicationCacheService; private readonly IEventService _eventService; + private readonly IFeatureService _featureService; + private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand; public UpdateSecretsManagerSubscriptionCommand( IOrganizationUserRepository organizationUserRepository, @@ -37,7 +41,9 @@ public UpdateSecretsManagerSubscriptionCommand( IGlobalSettings globalSettings, IOrganizationRepository organizationRepository, IApplicationCacheService applicationCacheService, - IEventService eventService) + IEventService eventService, + IUpdateOrganizationSubscriptionCommand updateOrganizationSubscriptionCommand, + IFeatureService featureService) { _organizationUserRepository = organizationUserRepository; _paymentService = paymentService; @@ -48,6 +54,8 @@ public UpdateSecretsManagerSubscriptionCommand( _organizationRepository = organizationRepository; _applicationCacheService = applicationCacheService; _eventService = eventService; + _updateOrganizationSubscriptionCommand = updateOrganizationSubscriptionCommand; + _featureService = featureService; } public async Task UpdateSubscriptionAsync(SecretsManagerSubscriptionUpdate update) @@ -61,19 +69,56 @@ public async Task UpdateSubscriptionAsync(SecretsManagerSubscriptionUpdate updat private async Task FinalizeSubscriptionAdjustmentAsync(SecretsManagerSubscriptionUpdate update) { - if (update.SmSeatsChanged) + if (_featureService.IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand)) { - await _paymentService.AdjustSmSeatsAsync(update.Organization, update.Plan, update.SmSeatsExcludingBase); + var builder = OrganizationSubscriptionChangeSet.Builder(); - // TODO: call ReferenceEventService - see AC-1481 - } + if (update.SmSeatsChanged) + { + builder.UpdateItemQuantity( + update.Plan.SecretsManager.StripeSeatPlanId, + update.SmSeatsExcludingBase); + } - if (update.SmServiceAccountsChanged) + if (update.SmServiceAccountsChanged) + { + if (update.Organization.SmServiceAccounts > update.Plan.SecretsManager.BaseServiceAccount) + { + builder.UpdateItemQuantity( + update.Plan.SecretsManager.StripeServiceAccountPlanId, + update.SmServiceAccountsExcludingBase); + } + else + { + builder.AddItem( + update.Plan.SecretsManager.StripeServiceAccountPlanId, + update.SmServiceAccountsExcludingBase); + } + } + + var changeSet = builder.Build(); + if (changeSet.Changes.Any()) + { + var result = await _updateOrganizationSubscriptionCommand.Run(update.Organization, changeSet); + result.GetValueOrThrow(); + } + } + else { - await _paymentService.AdjustServiceAccountsAsync(update.Organization, update.Plan, - update.SmServiceAccountsExcludingBase); + if (update.SmSeatsChanged) + { + await _paymentService.AdjustSmSeatsAsync(update.Organization, update.Plan, update.SmSeatsExcludingBase); + + // TODO: call ReferenceEventService - see AC-1481 + } + + if (update.SmServiceAccountsChanged) + { + await _paymentService.AdjustServiceAccountsAsync(update.Organization, update.Plan, + update.SmServiceAccountsExcludingBase); - // TODO: call ReferenceEventService - see AC-1481 + // TODO: call ReferenceEventService - see AC-1481 + } } var organization = update.Organization; diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 299eee7a6def..212c9b505a67 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -10,6 +10,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Pricing; @@ -41,6 +42,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand private readonly IFeatureService _featureService; private readonly IOrganizationBillingService _organizationBillingService; private readonly IPricingClient _pricingClient; + private readonly IUpgradeOrganizationPlanVNextCommand _upgradeOrganizationPlanVNextCommand; private readonly IUserRepository _userRepository; public UpgradeOrganizationPlanCommand( @@ -58,6 +60,7 @@ public UpgradeOrganizationPlanCommand( IFeatureService featureService, IOrganizationBillingService organizationBillingService, IPricingClient pricingClient, + IUpgradeOrganizationPlanVNextCommand upgradeOrganizationPlanVNextCommand, IUserRepository userRepository) { _organizationUserRepository = organizationUserRepository; @@ -74,17 +77,34 @@ public UpgradeOrganizationPlanCommand( _featureService = featureService; _organizationBillingService = organizationBillingService; _pricingClient = pricingClient; + _upgradeOrganizationPlanVNextCommand = upgradeOrganizationPlanVNextCommand; _userRepository = userRepository; } public async Task> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade, Guid? userId = null) { var organization = await GetOrgById(organizationId); + if (organization == null) { throw new NotFoundException(); } + /* + * Billing is going to take over this entire command as part of our refactoring work around the + * organization subscription upgrade process. + */ + if (_featureService.IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand)) + { + var plan = await _pricingClient.GetPlanOrThrow(upgrade.Plan); + var result = await _upgradeOrganizationPlanVNextCommand.Run( + organization, + plan, + upgrade.Keys); + result.GetValueOrThrow(); + return new Tuple(true, null); + } + if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) { throw new BadRequestException("Your account has no payment method available."); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 25bc577dccd2..d471daaa4caa 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -801,7 +801,14 @@ public async Task AdjustStorageAsync(User user, short storageAdjustmentG var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); var baseStorageGb = (short)premiumPlan.Storage.Provided; - var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, premiumPlan.Storage.StripePriceId, baseStorageGb); + var secret = await BillingHelpers.AdjustStorageAsync( + _paymentService, + null, + _featureService, + user, + storageAdjustmentGb, + premiumPlan.Storage.StripePriceId, + baseStorageGb); await SaveUserAsync(user); return secret; } diff --git a/src/Core/Utilities/BillingHelpers.cs b/src/Core/Utilities/BillingHelpers.cs index ef0fdf010b33..df152630f80a 100644 --- a/src/Core/Utilities/BillingHelpers.cs +++ b/src/Core/Utilities/BillingHelpers.cs @@ -1,13 +1,23 @@ -using Bit.Core.Billing.Services; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.Services; namespace Bit.Core.Utilities; public static class BillingHelpers { - internal static async Task AdjustStorageAsync(IStripePaymentService paymentService, IStorableSubscriber storableSubscriber, - short storageAdjustmentGb, string storagePlanId, short baseStorageGb) + internal static async Task AdjustStorageAsync( + IStripePaymentService paymentService, + IUpdateOrganizationSubscriptionCommand? updateOrganizationSubscriptionCommand, + IFeatureService featureService, + IStorableSubscriber storableSubscriber, + short storageAdjustmentGb, + string storagePlanId, + short baseStorageGb) { if (storableSubscriber == null) { @@ -49,6 +59,28 @@ internal static async Task AdjustStorageAsync(IStripePaymentService paym } var additionalStorage = newStorageGb - baseStorageGb; + + if (storableSubscriber is Organization organization && + updateOrganizationSubscriptionCommand != null && + featureService.IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand)) + { + var builder = OrganizationSubscriptionChangeSet.Builder(); + if (organization.MaxStorageGb > baseStorageGb) + { + builder.UpdateItemQuantity(storagePlanId, additionalStorage); + } + else + { + builder.AddItem(storagePlanId, additionalStorage); + } + + var changeSet = builder.Build(); + var result = await updateOrganizationSubscriptionCommand.Run(organization, changeSet); + result.GetValueOrThrow(); + storableSubscriber.MaxStorageGb = newStorageGb; + return null!; + } + var paymentIntentClientSecret = await paymentService.AdjustStorageAsync(storableSubscriber, additionalStorage, storagePlanId); storableSubscriber.MaxStorageGb = newStorageGb; diff --git a/test/Api.Test/AdminConsole/Jobs/OrganizationSubscriptionUpdateJobTests.cs b/test/Api.Test/AdminConsole/Jobs/OrganizationSubscriptionUpdateJobTests.cs index e500fcae1d3b..2681d2518906 100644 --- a/test/Api.Test/AdminConsole/Jobs/OrganizationSubscriptionUpdateJobTests.cs +++ b/test/Api.Test/AdminConsole/Jobs/OrganizationSubscriptionUpdateJobTests.cs @@ -31,9 +31,9 @@ await sutProvider.GetDependency() .DidNotReceive() .GetOrganizationSubscriptionsToUpdateAsync(); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .DidNotReceive() - .UpdateOrganizationSubscriptionAsync(Arg.Any>()); + .BulkUpdateOrganizationSubscriptionsAsync(Arg.Any>()); } [Theory] @@ -53,8 +53,8 @@ await sutProvider.GetDependency() .Received(1) .GetOrganizationSubscriptionsToUpdateAsync(); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .UpdateOrganizationSubscriptionAsync(Arg.Any>()); + .BulkUpdateOrganizationSubscriptionsAsync(Arg.Any>()); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/BulkUpdateOrganizationSubscriptionsCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/BulkUpdateOrganizationSubscriptionsCommandTests.cs new file mode 100644 index 000000000000..fb0eec87c26e --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/BulkUpdateOrganizationSubscriptionsCommandTests.cs @@ -0,0 +1,280 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Models.StaticStore; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.Billing.Mocks.Plans; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; +using OrganizationSubscriptionUpdate = Bit.Core.AdminConsole.Models.Data.Organizations.OrganizationSubscriptionUpdate; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations; + +[SutProviderCustomize] +public class BulkUpdateOrganizationSubscriptionsCommandTests +{ + [Theory] + [BitAutoData] + public async Task BulkUpdateOrganizationSubscriptionsAsync_WhenNoSubscriptionsNeedToBeUpdated_ThenNoSyncsOccur( + SutProvider sutProvider) + { + // Arrange + OrganizationSubscriptionUpdate[] subscriptionsToUpdate = []; + + // Act + await sutProvider.Sut.BulkUpdateOrganizationSubscriptionsAsync(subscriptionsToUpdate); + + await sutProvider.GetDependency() + .DidNotReceive() + .AdjustSeatsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateSuccessfulOrganizationSyncStatusAsync(Arg.Any>(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task BulkUpdateOrganizationSubscriptionsAsync_WhenOrgUpdatePassedIn_ThenSyncedThroughPaymentService( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually2023; + organization.Seats = 2; + + OrganizationSubscriptionUpdate[] subscriptionsToUpdate = + [new() { Organization = organization, Plan = new Enterprise2023Plan(true) }]; + + // Act + await sutProvider.Sut.BulkUpdateOrganizationSubscriptionsAsync(subscriptionsToUpdate); + + await sutProvider.GetDependency() + .Received(1) + .AdjustSeatsAsync( + Arg.Is(x => x.Id == organization.Id), + Arg.Is(x => x.Type == organization.PlanType), + organization.Seats!.Value); + + await sutProvider.GetDependency() + .Received(1) + .UpdateSuccessfulOrganizationSyncStatusAsync( + Arg.Is>(x => x.Contains(organization.Id)), + Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task BulkUpdateOrganizationSubscriptionsAsync_WhenOrgUpdateFails_ThenSyncDoesNotOccur( + Organization organization, + Exception exception, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually2023; + organization.Seats = 2; + + OrganizationSubscriptionUpdate[] subscriptionsToUpdate = + [new() { Organization = organization, Plan = new Enterprise2023Plan(true) }]; + + sutProvider.GetDependency() + .AdjustSeatsAsync( + Arg.Is(x => x.Id == organization.Id), + Arg.Is(x => x.Type == organization.PlanType), + organization.Seats!.Value).ThrowsAsync(exception); + + // Act + await sutProvider.Sut.BulkUpdateOrganizationSubscriptionsAsync(subscriptionsToUpdate); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateSuccessfulOrganizationSyncStatusAsync(Arg.Any>(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task BulkUpdateOrganizationSubscriptionsAsync_WhenOneOrgUpdateFailsAndAnotherSucceeds_ThenSyncOccursForTheSuccessfulOrg( + Organization successfulOrganization, + Organization failedOrganization, + Exception exception, + SutProvider sutProvider) + { + // Arrange + successfulOrganization.PlanType = PlanType.EnterpriseAnnually2023; + successfulOrganization.Seats = 2; + failedOrganization.PlanType = PlanType.EnterpriseAnnually2023; + failedOrganization.Seats = 2; + + OrganizationSubscriptionUpdate[] subscriptionsToUpdate = + [ + new() { Organization = successfulOrganization, Plan = new Enterprise2023Plan(true) }, + new() { Organization = failedOrganization, Plan = new Enterprise2023Plan(true) } + ]; + + sutProvider.GetDependency() + .AdjustSeatsAsync( + Arg.Is(x => x.Id == failedOrganization.Id), + Arg.Is(x => x.Type == failedOrganization.PlanType), + failedOrganization.Seats!.Value).ThrowsAsync(exception); + + // Act + await sutProvider.Sut.BulkUpdateOrganizationSubscriptionsAsync(subscriptionsToUpdate); + + await sutProvider.GetDependency() + .Received(1) + .AdjustSeatsAsync( + Arg.Is(x => x.Id == successfulOrganization.Id), + Arg.Is(x => x.Type == successfulOrganization.PlanType), + successfulOrganization.Seats!.Value); + + await sutProvider.GetDependency() + .Received(1) + .UpdateSuccessfulOrganizationSyncStatusAsync( + Arg.Is>(x => x.Contains(successfulOrganization.Id)), + Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateSuccessfulOrganizationSyncStatusAsync( + Arg.Is>(x => x.Contains(failedOrganization.Id)), + Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task BulkUpdateOrganizationSubscriptionsAsync_WithFeatureFlag_WhenOrgUpdatePassedIn_ThenSyncedThroughCommand( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually2023; + organization.Seats = 2; + + var plan = new Enterprise2023Plan(true); + + OrganizationSubscriptionUpdate[] subscriptionsToUpdate = + [new() { Organization = organization, Plan = plan }]; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(true); + + BillingCommandResult successResult = new Stripe.Subscription(); + sutProvider.GetDependency() + .Run(Arg.Is(x => x.Id == organization.Id), Arg.Any()) + .Returns(successResult); + + // Act + await sutProvider.Sut.BulkUpdateOrganizationSubscriptionsAsync(subscriptionsToUpdate); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .Run( + Arg.Is(x => x.Id == organization.Id), + Arg.Is(cs => + cs.Changes.Count == 1 && + cs.Changes[0].IsItemQuantityUpdate && + cs.Changes[0].AsT3.PriceId == plan.PasswordManager.StripeSeatPlanId && + cs.Changes[0].AsT3.Quantity == organization.Seats!.Value)); + + await sutProvider.GetDependency() + .DidNotReceive() + .AdjustSeatsAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .Received(1) + .UpdateSuccessfulOrganizationSyncStatusAsync( + Arg.Is>(x => x.Contains(organization.Id)), + Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task BulkUpdateOrganizationSubscriptionsAsync_WithFeatureFlag_WhenOrgUpdateFails_ThenSyncDoesNotOccur( + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = PlanType.EnterpriseAnnually2023; + organization.Seats = 2; + + OrganizationSubscriptionUpdate[] subscriptionsToUpdate = + [new() { Organization = organization, Plan = new Enterprise2023Plan(true) }]; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(true); + + BillingCommandResult failureResult = new BadRequest("error"); + sutProvider.GetDependency() + .Run(Arg.Any(), Arg.Any()) + .Returns(failureResult); + + // Act + await sutProvider.Sut.BulkUpdateOrganizationSubscriptionsAsync(subscriptionsToUpdate); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateSuccessfulOrganizationSyncStatusAsync(Arg.Any>(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task BulkUpdateOrganizationSubscriptionsAsync_WithFeatureFlag_WhenOneFailsAndOneSucceeds_ThenSyncOccursForSuccessfulOrg( + Organization successfulOrganization, + Organization failedOrganization, + SutProvider sutProvider) + { + // Arrange + successfulOrganization.PlanType = PlanType.EnterpriseAnnually2023; + successfulOrganization.Seats = 2; + failedOrganization.PlanType = PlanType.EnterpriseAnnually2023; + failedOrganization.Seats = 2; + + OrganizationSubscriptionUpdate[] subscriptionsToUpdate = + [ + new() { Organization = successfulOrganization, Plan = new Enterprise2023Plan(true) }, + new() { Organization = failedOrganization, Plan = new Enterprise2023Plan(true) } + ]; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(true); + + BillingCommandResult successResult = new Stripe.Subscription(); + sutProvider.GetDependency() + .Run(Arg.Is(x => x.Id == successfulOrganization.Id), Arg.Any()) + .Returns(successResult); + + BillingCommandResult failureResult = new BadRequest("error"); + sutProvider.GetDependency() + .Run(Arg.Is(x => x.Id == failedOrganization.Id), Arg.Any()) + .Returns(failureResult); + + // Act + await sutProvider.Sut.BulkUpdateOrganizationSubscriptionsAsync(subscriptionsToUpdate); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpdateSuccessfulOrganizationSyncStatusAsync( + Arg.Is>(x => x.Contains(successfulOrganization.Id)), + Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateSuccessfulOrganizationSyncStatusAsync( + Arg.Is>(x => x.Contains(failedOrganization.Id)), + Arg.Any()); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommandTests.cs deleted file mode 100644 index 47872cc6ab8e..000000000000 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/UpdateOrganizationSubscriptionCommandTests.cs +++ /dev/null @@ -1,146 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Models.Data.Organizations; -using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Services; -using Bit.Core.Models.StaticStore; -using Bit.Core.Repositories; -using Bit.Core.Test.Billing.Mocks.Plans; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using NSubstitute.ExceptionExtensions; -using Xunit; - -namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations; - -[SutProviderCustomize] -public class UpdateOrganizationSubscriptionCommandTests -{ - [Theory] - [BitAutoData] - public async Task UpdateOrganizationSubscriptionAsync_WhenNoSubscriptionsNeedToBeUpdated_ThenNoSyncsOccur( - SutProvider sutProvider) - { - // Arrange - OrganizationSubscriptionUpdate[] subscriptionsToUpdate = []; - - // Act - await sutProvider.Sut.UpdateOrganizationSubscriptionAsync(subscriptionsToUpdate); - - await sutProvider.GetDependency() - .DidNotReceive() - .AdjustSeatsAsync(Arg.Any(), Arg.Any(), Arg.Any()); - - await sutProvider.GetDependency() - .DidNotReceive() - .UpdateSuccessfulOrganizationSyncStatusAsync(Arg.Any>(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task UpdateOrganizationSubscriptionAsync_WhenOrgUpdatePassedIn_ThenSyncedThroughPaymentService( - Organization organization, - SutProvider sutProvider) - { - // Arrange - organization.PlanType = PlanType.EnterpriseAnnually2023; - organization.Seats = 2; - - OrganizationSubscriptionUpdate[] subscriptionsToUpdate = - [new() { Organization = organization, Plan = new Enterprise2023Plan(true) }]; - - // Act - await sutProvider.Sut.UpdateOrganizationSubscriptionAsync(subscriptionsToUpdate); - - await sutProvider.GetDependency() - .Received(1) - .AdjustSeatsAsync( - Arg.Is(x => x.Id == organization.Id), - Arg.Is(x => x.Type == organization.PlanType), - organization.Seats!.Value); - - await sutProvider.GetDependency() - .Received(1) - .UpdateSuccessfulOrganizationSyncStatusAsync( - Arg.Is>(x => x.Contains(organization.Id)), - Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task UpdateOrganizationSubscriptionAsync_WhenOrgUpdateFails_ThenSyncDoesNotOccur( - Organization organization, - Exception exception, - SutProvider sutProvider) - { - // Arrange - organization.PlanType = PlanType.EnterpriseAnnually2023; - organization.Seats = 2; - - OrganizationSubscriptionUpdate[] subscriptionsToUpdate = - [new() { Organization = organization, Plan = new Enterprise2023Plan(true) }]; - - sutProvider.GetDependency() - .AdjustSeatsAsync( - Arg.Is(x => x.Id == organization.Id), - Arg.Is(x => x.Type == organization.PlanType), - organization.Seats!.Value).ThrowsAsync(exception); - - // Act - await sutProvider.Sut.UpdateOrganizationSubscriptionAsync(subscriptionsToUpdate); - - await sutProvider.GetDependency() - .DidNotReceive() - .UpdateSuccessfulOrganizationSyncStatusAsync(Arg.Any>(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task UpdateOrganizationSubscriptionAsync_WhenOneOrgUpdateFailsAndAnotherSucceeds_ThenSyncOccursForTheSuccessfulOrg( - Organization successfulOrganization, - Organization failedOrganization, - Exception exception, - SutProvider sutProvider) - { - // Arrange - successfulOrganization.PlanType = PlanType.EnterpriseAnnually2023; - successfulOrganization.Seats = 2; - failedOrganization.PlanType = PlanType.EnterpriseAnnually2023; - failedOrganization.Seats = 2; - - OrganizationSubscriptionUpdate[] subscriptionsToUpdate = - [ - new() { Organization = successfulOrganization, Plan = new Enterprise2023Plan(true) }, - new() { Organization = failedOrganization, Plan = new Enterprise2023Plan(true) } - ]; - - sutProvider.GetDependency() - .AdjustSeatsAsync( - Arg.Is(x => x.Id == failedOrganization.Id), - Arg.Is(x => x.Type == failedOrganization.PlanType), - failedOrganization.Seats!.Value).ThrowsAsync(exception); - - // Act - await sutProvider.Sut.UpdateOrganizationSubscriptionAsync(subscriptionsToUpdate); - - await sutProvider.GetDependency() - .Received(1) - .AdjustSeatsAsync( - Arg.Is(x => x.Id == successfulOrganization.Id), - Arg.Is(x => x.Type == successfulOrganization.PlanType), - successfulOrganization.Seats!.Value); - - await sutProvider.GetDependency() - .Received(1) - .UpdateSuccessfulOrganizationSyncStatusAsync( - Arg.Is>(x => x.Contains(successfulOrganization.Id)), - Arg.Any()); - - await sutProvider.GetDependency() - .DidNotReceive() - .UpdateSuccessfulOrganizationSyncStatusAsync( - Arg.Is>(x => x.Contains(failedOrganization.Id)), - Arg.Any()); - } -} diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 43a33cda31ea..379267abdb78 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -7,7 +7,10 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -1331,6 +1334,173 @@ await sutProvider.GetDependency() .GetByIdAsync(organizationId); } + [Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.EnterpriseAnnually), BitAutoData] + public async Task AdjustSeatsAsync_WithFeatureFlag_UsesUpdateOrganizationSubscriptionCommand( + Organization organization, SutProvider sutProvider) + { + organization.Seats = 20; + organization.GatewayCustomerId = "cus_123"; + organization.GatewaySubscriptionId = "sub_123"; + organization.UseSecretsManager = false; + + var plan = MockPlans.Get(organization.PlanType); + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(plan); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(true); + + BillingCommandResult successResult = new Subscription(); + sutProvider.GetDependency() + .Run(organization, Arg.Any()) + .Returns(successResult); + + await sutProvider.Sut.AdjustSeatsAsync(organization.Id, 2); + + await sutProvider.GetDependency().Received(1) + .Run(organization, Arg.Any()); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AdjustSeatsAsync(default, default, default); + } + + [Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.EnterpriseAnnually), BitAutoData] + public async Task AdjustSeatsAsync_WithoutFeatureFlag_UsesPaymentService( + Organization organization, SutProvider sutProvider) + { + organization.Seats = 20; + organization.GatewayCustomerId = "cus_123"; + organization.GatewaySubscriptionId = "sub_123"; + organization.UseSecretsManager = false; + + var plan = MockPlans.Get(organization.PlanType); + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(plan); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(false); + + await sutProvider.Sut.AdjustSeatsAsync(organization.Id, 2); + + await sutProvider.GetDependency().Received(1) + .AdjustSeatsAsync(organization, plan, Arg.Any()); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .Run(default, default); + } + + [Theory, BitAutoData] + public async Task AdjustStorageAsync_OrganizationNotFound_ThrowsNotFoundException( + Guid organizationId, SutProvider sutProvider) + { + sutProvider.GetDependency().GetByIdAsync(organizationId).Returns((Organization)null); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AdjustStorageAsync(organizationId, 1)); + } + + [Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.Free), BitAutoData] + public async Task AdjustStorageAsync_PlanDoesNotAllowStorage_ThrowsBadRequestException( + Organization organization, SutProvider sutProvider) + { + var plan = MockPlans.Get(organization.PlanType); + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(plan); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AdjustStorageAsync(organization.Id, 1)); + } + + [Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.EnterpriseAnnually), BitAutoData] + public async Task AdjustStorageAsync_WithFeatureFlag_AtBaseStorage_AddsItem( + Organization organization, SutProvider sutProvider) + { + organization.GatewayCustomerId = "cus_123"; + organization.GatewaySubscriptionId = "sub_123"; + organization.MaxStorageGb = 1; + organization.Storage = 0; + + var plan = MockPlans.Get(organization.PlanType); + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(plan); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(true); + + BillingCommandResult successResult = new Subscription(); + sutProvider.GetDependency() + .Run(organization, Arg.Any()) + .Returns(successResult); + + await sutProvider.Sut.AdjustStorageAsync(organization.Id, 1); + + await sutProvider.GetDependency().Received(1) + .Run(organization, Arg.Is(cs => + cs.Changes.Count == 1 && cs.Changes[0].IsItemAddition)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AdjustStorageAsync(default, default, default); + Assert.Equal((short)2, organization.MaxStorageGb); + } + + [Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.EnterpriseAnnually), BitAutoData] + public async Task AdjustStorageAsync_WithFeatureFlag_AboveBaseStorage_UpdatesItemQuantity( + Organization organization, SutProvider sutProvider) + { + organization.GatewayCustomerId = "cus_123"; + organization.GatewaySubscriptionId = "sub_123"; + organization.MaxStorageGb = 2; + organization.Storage = 0; + + var plan = MockPlans.Get(organization.PlanType); + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(plan); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(true); + + BillingCommandResult successResult = new Subscription(); + sutProvider.GetDependency() + .Run(organization, Arg.Any()) + .Returns(successResult); + + await sutProvider.Sut.AdjustStorageAsync(organization.Id, 1); + + await sutProvider.GetDependency().Received(1) + .Run(organization, Arg.Is(cs => + cs.Changes.Count == 1 && cs.Changes[0].IsItemQuantityUpdate)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AdjustStorageAsync(default, default, default); + Assert.Equal((short)3, organization.MaxStorageGb); + } + + [Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.EnterpriseAnnually), BitAutoData] + public async Task AdjustStorageAsync_WithoutFeatureFlag_UsesPaymentService( + Organization organization, SutProvider sutProvider) + { + organization.GatewayCustomerId = "cus_123"; + organization.GatewaySubscriptionId = "sub_123"; + organization.MaxStorageGb = 1; + organization.Storage = 0; + + var plan = MockPlans.Get(organization.PlanType); + + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(plan); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(false); + + await sutProvider.Sut.AdjustStorageAsync(organization.Id, 1); + + await sutProvider.GetDependency().Received(1) + .AdjustStorageAsync(organization, Arg.Any(), plan.PasswordManager.StripeStoragePlanId); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .Run(default, default); + } + // Must set real guids in order for dictionary of guids to not throw aggregate exceptions private void SetupOrgUserRepositoryCreateManyAsyncMock(IOrganizationUserRepository organizationUserRepository) { diff --git a/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommandTests.cs new file mode 100644 index 000000000000..7114b8b675be --- /dev/null +++ b/test/Core.Test/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommandTests.cs @@ -0,0 +1,910 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Organizations.Commands; + +using static StripeConstants; + +public class UpdateOrganizationSubscriptionCommandTests +{ + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly UpdateOrganizationSubscriptionCommand _command; + + public UpdateOrganizationSubscriptionCommandTests() + { + _command = new UpdateOrganizationSubscriptionCommand( + Substitute.For>(), + _stripeAdapter); + } + + [Fact] + public async Task Run_SubscriptionNotFound_ReturnsBadRequest() + { + var organization = CreateOrganization(); + + _stripeAdapter + .GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()) + .Returns(_ => throw new StripeException { StripeError = new StripeError { Code = ErrorCodes.ResourceMissing } }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.IsT1); + Assert.Equal("We couldn't find your subscription.", result.AsT1.Response); + } + + [Theory] + [InlineData(SubscriptionStatus.Canceled)] + [InlineData(SubscriptionStatus.Incomplete)] + [InlineData(SubscriptionStatus.IncompleteExpired)] + [InlineData(SubscriptionStatus.Unpaid)] + [InlineData(SubscriptionStatus.Paused)] + public async Task Run_InvalidSubscriptionStatus_ReturnsBadRequest(string status) + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(status: status, items: [("price_seats", "si_1", 5)]); + + _stripeAdapter + .GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.IsT1); + Assert.Equal("Your subscription cannot be updated in its current status.", result.AsT1.Response); + } + + [Theory] + [InlineData(SubscriptionStatus.Active)] + [InlineData(SubscriptionStatus.Trialing)] + [InlineData(SubscriptionStatus.PastDue)] + public async Task Run_ValidSubscriptionStatus_DoesNotReturnStatusError(string status) + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(status: status, items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + } + + [Fact] + public async Task Run_EmptyChangeSet_ReturnsConflict() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + + var changeSet = new OrganizationSubscriptionChangeSet { Changes = [] }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.IsT2); + Assert.Equal("No changes were provided for the organization subscription update", result.AsT2.Response); + } + + [Fact] + public async Task Run_AddItem_DuplicatePrice_ReturnsBadRequest() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new AddItem("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.IsT1); + Assert.Contains("price_seats", result.AsT1.Response); + } + + [Fact] + public async Task Run_AddItem_Valid_CreatesCorrectOptions() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new AddItem("price_storage", 3)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.Items.Count == 1 && + options.Items[0].Price == "price_storage" && + options.Items[0].Quantity == 3)); + } + + [Fact] + public async Task Run_ChangeItemPrice_MissingCurrentPrice_ReturnsBadRequest() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new ChangeItemPrice("price_nonexistent", "price_new", null)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.IsT1); + Assert.Contains("price_nonexistent", result.AsT1.Response); + } + + [Fact] + public async Task Run_ChangeItemPrice_Valid_PreservesExistingQuantity() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_monthly", "si_1", 10)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new ChangeItemPrice("price_monthly", "price_annual", null)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.Items.Count == 1 && + options.Items[0].Id == "si_1" && + options.Items[0].Price == "price_annual" && + options.Items[0].Quantity == 10)); + } + + [Fact] + public async Task Run_ChangeItemPrice_WithExplicitQuantity_UsesProvidedQuantity() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_monthly", "si_1", 10)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new ChangeItemPrice("price_monthly", "price_annual", 20)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.Items[0].Quantity == 20)); + } + + [Fact] + public async Task Run_RemoveItem_MissingPrice_ReturnsBadRequest() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new RemoveItem("price_nonexistent")] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.IsT1); + Assert.Contains("price_nonexistent", result.AsT1.Response); + } + + [Fact] + public async Task Run_RemoveItem_Valid_SetsDeletedTrue() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5), ("price_storage", "si_2", 1)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new RemoveItem("price_storage")] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.Items.Count == 1 && + options.Items[0].Id == "si_2" && + options.Items[0].Deleted == true)); + } + + [Fact] + public async Task Run_StripeExceptionDuringUpdate_ReturnsUnhandled() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + + _stripeAdapter + .UpdateSubscriptionAsync(subscription.Id, Arg.Any()) + .Returns(_ => throw new StripeException { StripeError = new StripeError { Code = "api_error" } }); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.IsT3); + } + + [Fact] + public async Task Run_UpdateItemQuantity_MissingPrice_ReturnsBadRequest() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_nonexistent", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.IsT1); + Assert.Contains("price_nonexistent", result.AsT1.Response); + } + + [Fact] + public async Task Run_UpdateItemQuantity_Valid_CreatesCorrectOptions() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 15)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.Items.Count == 1 && + options.Items[0].Id == "si_1" && + options.Items[0].Price == "price_seats" && + options.Items[0].Quantity == 15)); + } + + [Fact] + public async Task Run_UpdateItemQuantity_ZeroQuantity_SetsDeletedTrue() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 0)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.Items[0].Id == "si_1" && + options.Items[0].Deleted == true)); + } + + [Fact] + public async Task Run_StructuralChange_SetsAlwaysInvoiceProration() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + // AddItem is structural (IsStructural = !IsItemQuantityUpdate = true) + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new AddItem("price_storage", 1)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.ProrationBehavior == ProrationBehavior.AlwaysInvoice)); + } + + [Fact] + public async Task Run_NonStructuralChange_SetsCreateProrationsProration() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + // UpdateItemQuantity is non-structural + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.ProrationBehavior == ProrationBehavior.CreateProrations)); + } + + [Fact] + public async Task Run_StructuralChange_ChargeAutomatically_SetsPendingIfIncomplete() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription( + collectionMethod: CollectionMethod.ChargeAutomatically, + items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new AddItem("price_storage", 1)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.PaymentBehavior == PaymentBehavior.PendingIfIncomplete)); + } + + [Fact] + public async Task Run_StructuralChange_SendInvoice_NoPaymentBehavior() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription( + collectionMethod: CollectionMethod.SendInvoice, + items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new AddItem("price_storage", 1)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.PaymentBehavior == null)); + } + + [Fact] + public async Task Run_NonStructuralChange_ChargeAutomatically_NoPaymentBehavior() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription( + collectionMethod: CollectionMethod.ChargeAutomatically, + items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.PaymentBehavior == null)); + } + + [Fact] + public async Task Run_AnnualBilling_NonStructural_Active_SetsPendingInvoiceItemInterval() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription( + status: SubscriptionStatus.Active, + billingInterval: Intervals.Year, + items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.PendingInvoiceItemInterval != null && + options.PendingInvoiceItemInterval.Interval == Intervals.Month)); + } + + [Fact] + public async Task Run_AnnualBilling_NonStructural_Trialing_NoPendingInvoiceItemInterval() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription( + status: SubscriptionStatus.Trialing, + billingInterval: Intervals.Year, + items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.PendingInvoiceItemInterval == null)); + } + + [Fact] + public async Task Run_AnnualBilling_Structural_NoPendingInvoiceItemInterval() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription( + status: SubscriptionStatus.Active, + billingInterval: Intervals.Year, + items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new AddItem("price_storage", 1)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.PendingInvoiceItemInterval == null)); + } + + [Fact] + public async Task Run_MonthlyBilling_NonStructural_NoPendingInvoiceItemInterval() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription( + billingInterval: Intervals.Month, + items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => + options.PendingInvoiceItemInterval == null)); + } + + [Fact] + public async Task Run_SendInvoice_Structural_DraftInvoice_FinalizesAndSends() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription( + collectionMethod: CollectionMethod.SendInvoice, + items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + + var updatedSubscription = CreateSubscription( + collectionMethod: CollectionMethod.SendInvoice, + items: [("price_seats", "si_1", 5), ("price_storage", "si_2", 1)]); + updatedSubscription.LatestInvoiceId = "inv_123"; + + _stripeAdapter + .UpdateSubscriptionAsync(subscription.Id, Arg.Any()) + .Returns(updatedSubscription); + + var draftInvoice = new Invoice { Id = "inv_123", Status = InvoiceStatus.Draft }; + _stripeAdapter.GetInvoiceAsync("inv_123", Arg.Any()).Returns(draftInvoice); + + var finalizedInvoice = new Invoice { Id = "inv_123", Status = InvoiceStatus.Open }; + _stripeAdapter + .FinalizeInvoiceAsync("inv_123", Arg.Is(o => o.AutoAdvance == false)) + .Returns(finalizedInvoice); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new AddItem("price_storage", 1)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).GetInvoiceAsync("inv_123", Arg.Any()); + await _stripeAdapter.Received(1).FinalizeInvoiceAsync("inv_123", Arg.Any()); + await _stripeAdapter.Received(1).SendInvoiceAsync("inv_123"); + } + + [Fact] + public async Task Run_SendInvoice_Structural_NonDraftInvoice_DoesNotFinalizeOrSend() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription( + collectionMethod: CollectionMethod.SendInvoice, + items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + + var updatedSubscription = CreateSubscription( + collectionMethod: CollectionMethod.SendInvoice, + items: [("price_seats", "si_1", 5), ("price_storage", "si_2", 1)]); + updatedSubscription.LatestInvoiceId = "inv_123"; + + _stripeAdapter + .UpdateSubscriptionAsync(subscription.Id, Arg.Any()) + .Returns(updatedSubscription); + + var openInvoice = new Invoice { Id = "inv_123", Status = InvoiceStatus.Open }; + _stripeAdapter.GetInvoiceAsync("inv_123", Arg.Any()).Returns(openInvoice); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new AddItem("price_storage", 1)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).GetInvoiceAsync("inv_123", Arg.Any()); + await _stripeAdapter.DidNotReceive().FinalizeInvoiceAsync(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().SendInvoiceAsync(Arg.Any()); + } + + [Fact] + public async Task Run_ChargeAutomatically_Structural_DoesNotProcessInvoice() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription( + collectionMethod: CollectionMethod.ChargeAutomatically, + items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + + var updatedSubscription = CreateSubscription( + collectionMethod: CollectionMethod.ChargeAutomatically, + items: [("price_seats", "si_1", 5), ("price_storage", "si_2", 1)]); + updatedSubscription.LatestInvoiceId = "inv_123"; + + _stripeAdapter + .UpdateSubscriptionAsync(subscription.Id, Arg.Any()) + .Returns(updatedSubscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new AddItem("price_storage", 1)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.DidNotReceive().GetInvoiceAsync(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().FinalizeInvoiceAsync(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().SendInvoiceAsync(Arg.Any()); + } + + [Fact] + public async Task Run_SendInvoice_NonStructural_DoesNotProcessInvoice() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription( + collectionMethod: CollectionMethod.SendInvoice, + items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.DidNotReceive().GetInvoiceAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Run_NonUSCustomer_NotReverseExempt_UpdatesTaxExemption() + { + var customer = new Customer + { + Id = "cus_123", + Address = new Address { Country = "DE" }, + TaxExempt = TaxExempt.None + }; + + var organization = CreateOrganization(); + var subscription = CreateSubscription(customer: customer, items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + await _command.Run(organization, changeSet); + + await _stripeAdapter.Received(1).UpdateCustomerAsync(customer.Id, + Arg.Is(options => + options.TaxExempt == TaxExempt.Reverse)); + } + + [Fact] + public async Task Run_NonUSCustomer_AlreadyReverseExempt_DoesNotUpdateTaxExemption() + { + var customer = new Customer + { + Id = "cus_123", + Address = new Address { Country = "DE" }, + TaxExempt = TaxExempt.Reverse + }; + + var organization = CreateOrganization(); + var subscription = CreateSubscription(customer: customer, items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + await _command.Run(organization, changeSet); + + await _stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Run_USCustomer_DoesNotUpdateTaxExemption() + { + var customer = new Customer + { + Id = "cus_123", + Address = new Address { Country = "US" }, + TaxExempt = TaxExempt.None + }; + + var organization = CreateOrganization(); + var subscription = CreateSubscription(customer: customer, items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = [new UpdateItemQuantity("price_seats", 10)] + }; + + await _command.Run(organization, changeSet); + + await _stripeAdapter.DidNotReceive().UpdateCustomerAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Run_MultipleChanges_AllValid_CreatesAllItems() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: + [ + ("price_seats", "si_1", 5), + ("price_monthly", "si_2", 5) + ]); + + SetupGetSubscription(organization, subscription); + SetupUpdateSubscription(subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = + [ + new UpdateItemQuantity("price_seats", 10), + new ChangeItemPrice("price_monthly", "price_annual", null), + new AddItem("price_storage", 1) + ] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.Success); + + await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, + Arg.Is(options => options.Items.Count == 3)); + } + + [Fact] + public async Task Run_MultipleChanges_SecondInvalid_ReturnsBadRequest() + { + var organization = CreateOrganization(); + var subscription = CreateSubscription(items: [("price_seats", "si_1", 5)]); + + SetupGetSubscription(organization, subscription); + + var changeSet = new OrganizationSubscriptionChangeSet + { + Changes = + [ + new UpdateItemQuantity("price_seats", 10), + new RemoveItem("price_nonexistent") + ] + }; + + var result = await _command.Run(organization, changeSet); + + Assert.True(result.IsT1); + + await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync( + Arg.Any(), Arg.Any()); + } + + private static Organization CreateOrganization() => new() + { + Id = Guid.NewGuid(), + GatewaySubscriptionId = "sub_123" + }; + + private static Subscription CreateSubscription( + string status = SubscriptionStatus.Active, + string collectionMethod = CollectionMethod.ChargeAutomatically, + string billingInterval = Intervals.Month, + Customer? customer = null, + params (string priceId, string itemId, long quantity)[] items) + { + return new Subscription + { + Id = "sub_123", + Status = status, + CollectionMethod = collectionMethod, + Customer = customer ?? new Customer + { + Id = "cus_123", + Address = new Address { Country = "US" }, + TaxExempt = TaxExempt.None + }, + Items = new StripeList + { + Data = items.Select(i => new SubscriptionItem + { + Id = i.itemId, + Price = new Price + { + Id = i.priceId, + Recurring = new PriceRecurring { Interval = billingInterval } + }, + Quantity = i.quantity + }).ToList() + } + }; + } + + private void SetupGetSubscription(Organization organization, Subscription subscription) + { + _stripeAdapter + .GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + } + + private void SetupUpdateSubscription(Subscription subscription) + { + _stripeAdapter + .UpdateSubscriptionAsync(subscription.Id, Arg.Any()) + .Returns(subscription); + } + +} diff --git a/test/Core.Test/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommandTests.cs new file mode 100644 index 000000000000..1f027e5a22a1 --- /dev/null +++ b/test/Core.Test/Billing/Organizations/Commands/UpgradeOrganizationPlanVNextCommandTests.cs @@ -0,0 +1,373 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Organizations.Services; +using Bit.Core.Billing.Pricing; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Services; +using Bit.Core.Test.Billing.Mocks; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Organizations.Commands; + +public class UpgradeOrganizationPlanVNextCommandTests +{ + private readonly IOrganizationBillingService _organizationBillingService = Substitute.For(); + private readonly IOrganizationService _organizationService = Substitute.For(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IUpdateOrganizationSubscriptionCommand _updateOrganizationSubscriptionCommand = Substitute.For(); + private readonly UpgradeOrganizationPlanVNextCommand _command; + + public UpgradeOrganizationPlanVNextCommandTests() + { + _command = new UpgradeOrganizationPlanVNextCommand( + Substitute.For>(), + _organizationBillingService, + _organizationService, + _pricingClient, + _updateOrganizationSubscriptionCommand); + } + + [Fact] + public async Task Run_SamePlan_ReturnsBadRequest() + { + var organization = CreateOrganization(PlanType.TeamsAnnually); + var currentPlan = MockPlans.Get(PlanType.TeamsAnnually); + var targetPlan = MockPlans.Get(PlanType.TeamsAnnually); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + + var result = await _command.Run(organization, targetPlan, null); + + Assert.True(result.IsT1); + Assert.Equal("Your organization is already on this plan.", result.AsT1.Response); + } + + [Fact] + public async Task Run_Downgrade_ReturnsBadRequest() + { + var organization = CreateOrganization(PlanType.EnterpriseAnnually); + var currentPlan = MockPlans.Get(PlanType.EnterpriseAnnually); + var targetPlan = MockPlans.Get(PlanType.TeamsAnnually); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + + var result = await _command.Run(organization, targetPlan, null); + + Assert.True(result.IsT1); + Assert.Equal("You can't downgrade your organization's plan.", result.AsT1.Response); + } + + [Fact] + public async Task Run_NoGatewayCustomerId_ReturnsConflict() + { + var organization = CreateOrganization(PlanType.TeamsAnnually, gatewayCustomerId: null); + var currentPlan = MockPlans.Get(PlanType.TeamsAnnually); + var targetPlan = MockPlans.Get(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + + var result = await _command.Run(organization, targetPlan, null); + + Assert.True(result.IsT2); + } + + [Fact] + public async Task Run_UpgradeFromFree_FinalizesAndUpdatesOrganization() + { + var organization = CreateOrganization( + PlanType.Free, + gatewaySubscriptionId: null, + seats: 2); + var currentPlan = MockPlans.Get(PlanType.Free); + var targetPlan = MockPlans.Get(PlanType.TeamsAnnually); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + + var result = await _command.Run(organization, targetPlan, null); + + Assert.True(result.IsT0); + await _organizationBillingService.Received(1).Finalize(Arg.Any()); + await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization, null); + Assert.Equal(targetPlan.Name, organization.Plan); + Assert.Equal(targetPlan.Type, organization.PlanType); + Assert.Equal(targetPlan.PasswordManager.BaseStorageGb, organization.MaxStorageGb); + Assert.Null(organization.SmServiceAccounts); + } + + [Fact] + public async Task Run_UpgradeFromFree_WithKeys_BackfillsKeys() + { + var organization = CreateOrganization( + PlanType.Free, + gatewaySubscriptionId: null, + seats: 2); + var currentPlan = MockPlans.Get(PlanType.Free); + var targetPlan = MockPlans.Get(PlanType.TeamsAnnually); + var keys = new PublicKeyEncryptionKeyPairData("wrappedPrivateKey", "publicKey"); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + + var result = await _command.Run(organization, targetPlan, keys); + + Assert.True(result.IsT0); + Assert.Equal("publicKey", organization.PublicKey); + Assert.Equal("wrappedPrivateKey", organization.PrivateKey); + } + + [Fact] + public async Task Run_UpgradeFromFree_SetsSecretsManagerOnSale() + { + var organization = CreateOrganization( + PlanType.Free, + gatewaySubscriptionId: null, + seats: 2, + useSecretsManager: true, + smSeats: 2); + var currentPlan = MockPlans.Get(PlanType.Free); + var targetPlan = MockPlans.Get(PlanType.TeamsAnnually); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + + var result = await _command.Run(organization, targetPlan, null); + + Assert.True(result.IsT0); + await _organizationBillingService.Received(1).Finalize( + Arg.Is(s => + s.SubscriptionSetup.SecretsManagerOptions != null)); + } + + [Fact] + public async Task Run_UpgradeFromFree_WithSecretsManager_SetsSmServiceAccountsToNewPlanBase() + { + var freePlan = MockPlans.Get(PlanType.Free); + var organization = CreateOrganization( + PlanType.Free, + gatewaySubscriptionId: null, + seats: 2, + useSecretsManager: true, + smSeats: 2, + smServiceAccounts: freePlan.SecretsManager.BaseServiceAccount); + var targetPlan = MockPlans.Get(PlanType.TeamsAnnually); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(freePlan); + + var result = await _command.Run(organization, targetPlan, null); + + Assert.True(result.IsT0); + Assert.Equal(targetPlan.SecretsManager.BaseServiceAccount, organization.SmServiceAccounts); + } + + [Fact] + public async Task Run_PaidUpgrade_ChangesPasswordManagerPrice() + { + var organization = CreateOrganization(PlanType.TeamsAnnually); + var currentPlan = MockPlans.Get(PlanType.TeamsAnnually); + var targetPlan = MockPlans.Get(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + SetupSubscriptionCommandSuccess(); + + var result = await _command.Run(organization, targetPlan, null); + + await _updateOrganizationSubscriptionCommand.Received(1).Run( + organization, + Arg.Is(cs => + cs.Changes.Count >= 1 && + cs.Changes.Any(c => c.IsItemPriceChange))); + await _organizationService.Received(1).ReplaceAndUpdateCacheAsync(organization, null); + Assert.Equal(targetPlan.Name, organization.Plan); + Assert.Equal(targetPlan.Type, organization.PlanType); + } + + [Fact] + public async Task Run_PaidUpgrade_WithExtraStorage_ChangesStoragePrice() + { + var currentPlan = MockPlans.Get(PlanType.TeamsAnnually); + var organization = CreateOrganization( + PlanType.TeamsAnnually, + maxStorageGb: (short)(currentPlan.PasswordManager.BaseStorageGb + 1)); + var targetPlan = MockPlans.Get(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + SetupSubscriptionCommandSuccess(); + + var result = await _command.Run(organization, targetPlan, null); + + await _updateOrganizationSubscriptionCommand.Received(1).Run( + organization, + Arg.Is(cs => + cs.Changes.Count(c => c.IsItemPriceChange) == 2)); + } + + [Fact] + public async Task Run_PaidUpgrade_WithSecretsManager_ChangesSmSeatPrice() + { + var organization = CreateOrganization( + PlanType.TeamsAnnually, + useSecretsManager: true, + smSeats: 5); + var currentPlan = MockPlans.Get(PlanType.TeamsAnnually); + var targetPlan = MockPlans.Get(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + SetupSubscriptionCommandSuccess(); + + var result = await _command.Run(organization, targetPlan, null); + + await _updateOrganizationSubscriptionCommand.Received(1).Run( + organization, + Arg.Is(cs => + cs.Changes.Count(c => c.IsItemPriceChange) >= 2)); + } + + [Fact] + public async Task Run_PaidUpgrade_WithSmServiceAccountsAboveBase_ChangesServiceAccountPrice() + { + var currentPlan = MockPlans.Get(PlanType.TeamsAnnually); + var organization = CreateOrganization( + PlanType.TeamsAnnually, + useSecretsManager: true, + smSeats: 5, + smServiceAccounts: currentPlan.SecretsManager.BaseServiceAccount + 10); + + var targetPlan = MockPlans.Get(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + SetupSubscriptionCommandSuccess(); + + var result = await _command.Run(organization, targetPlan, null); + + // PM seat + SM seat + SM service account = 3 price changes + await _updateOrganizationSubscriptionCommand.Received(1).Run( + organization, + Arg.Is(cs => + cs.Changes.Count(c => c.IsItemPriceChange) == 3)); + } + + [Fact] + public async Task Run_PaidUpgrade_UpdatesAllOrganizationPlanProperties() + { + var organization = CreateOrganization(PlanType.TeamsAnnually); + var currentPlan = MockPlans.Get(PlanType.TeamsAnnually); + var targetPlan = MockPlans.Get(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + SetupSubscriptionCommandSuccess(); + + await _command.Run(organization, targetPlan, null); + + Assert.Equal(targetPlan.Name, organization.Plan); + Assert.Equal(targetPlan.Type, organization.PlanType); + Assert.Equal(targetPlan.PasswordManager.MaxCollections, organization.MaxCollections); + Assert.Equal(targetPlan.HasPolicies, organization.UsePolicies); + Assert.Equal(targetPlan.HasSso, organization.UseSso); + Assert.Equal(targetPlan.HasKeyConnector, organization.UseKeyConnector); + Assert.Equal(targetPlan.HasScim, organization.UseScim); + Assert.Equal(targetPlan.HasGroups, organization.UseGroups); + Assert.Equal(targetPlan.HasDirectory, organization.UseDirectory); + Assert.Equal(targetPlan.HasEvents, organization.UseEvents); + Assert.Equal(targetPlan.HasTotp, organization.UseTotp); + Assert.Equal(targetPlan.Has2fa, organization.Use2fa); + Assert.Equal(targetPlan.HasApi, organization.UseApi); + Assert.Equal(targetPlan.HasResetPassword, organization.UseResetPassword); + Assert.Equal(targetPlan.HasSelfHost, organization.SelfHost); + Assert.Equal(targetPlan.UsersGetPremium, organization.UsersGetPremium); + Assert.Equal(targetPlan.HasCustomPermissions, organization.UseCustomPermissions); + Assert.Equal(targetPlan.HasOrganizationDomains, organization.UseOrganizationDomains); + Assert.Equal(targetPlan.AutomaticUserConfirmation, organization.UseAutomaticUserConfirmation); + Assert.Equal(targetPlan.HasMyItems, organization.UseMyItems); + } + + [Fact] + public async Task Run_PaidUpgrade_WithKeys_BackfillsKeys() + { + var organization = CreateOrganization(PlanType.TeamsAnnually); + var currentPlan = MockPlans.Get(PlanType.TeamsAnnually); + var targetPlan = MockPlans.Get(PlanType.EnterpriseAnnually); + var keys = new PublicKeyEncryptionKeyPairData("wrappedPrivateKey", "publicKey"); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + SetupSubscriptionCommandSuccess(); + + await _command.Run(organization, targetPlan, keys); + + Assert.Equal("publicKey", organization.PublicKey); + Assert.Equal("wrappedPrivateKey", organization.PrivateKey); + } + + [Fact] + public async Task Run_PaidUpgrade_WithoutKeys_DoesNotOverwriteKeys() + { + var organization = CreateOrganization(PlanType.TeamsAnnually); + organization.PublicKey = "existingPublic"; + organization.PrivateKey = "existingPrivate"; + var currentPlan = MockPlans.Get(PlanType.TeamsAnnually); + var targetPlan = MockPlans.Get(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + SetupSubscriptionCommandSuccess(); + + await _command.Run(organization, targetPlan, null); + + Assert.Equal("existingPublic", organization.PublicKey); + Assert.Equal("existingPrivate", organization.PrivateKey); + } + + [Fact] + public async Task Run_PaidUpgrade_CommandFailure_PropagatesResult() + { + var organization = CreateOrganization(PlanType.TeamsAnnually); + var currentPlan = MockPlans.Get(PlanType.TeamsAnnually); + var targetPlan = MockPlans.Get(PlanType.EnterpriseAnnually); + + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + + BillingCommandResult failureResult = new BadRequest("Stripe error"); + _updateOrganizationSubscriptionCommand + .Run(organization, Arg.Any()) + .Returns(failureResult); + + var result = await _command.Run(organization, targetPlan, null); + + // Result is mapped through — BadRequest becomes T1 + Assert.True(result.IsT1); + await _organizationService.DidNotReceive().ReplaceAndUpdateCacheAsync(Arg.Any(), Arg.Any()); + } + + private void SetupSubscriptionCommandSuccess() + { + BillingCommandResult successResult = new Subscription(); + _updateOrganizationSubscriptionCommand + .Run(Arg.Any(), Arg.Any()) + .Returns(successResult); + } + + private static Organization CreateOrganization( + PlanType planType, + string? gatewayCustomerId = "cus_test123", + string? gatewaySubscriptionId = "sub_test123", + int? seats = 10, + short? maxStorageGb = null, + bool useSecretsManager = false, + int? smSeats = null, + int? smServiceAccounts = null) => new() + { + Id = Guid.NewGuid(), + PlanType = planType, + Plan = MockPlans.Get(planType).Name, + GatewayCustomerId = gatewayCustomerId, + GatewaySubscriptionId = gatewaySubscriptionId, + Seats = seats, + MaxStorageGb = maxStorageGb, + UseSecretsManager = useSecretsManager, + SmSeats = smSeats, + SmServiceAccounts = smServiceAccounts + }; +} diff --git a/test/Core.Test/Billing/Organizations/Models/OrganizationSubscriptionChangeSetTests.cs b/test/Core.Test/Billing/Organizations/Models/OrganizationSubscriptionChangeSetTests.cs new file mode 100644 index 000000000000..1be218c30b82 --- /dev/null +++ b/test/Core.Test/Billing/Organizations/Models/OrganizationSubscriptionChangeSetTests.cs @@ -0,0 +1,254 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Test.Billing.Mocks; +using Xunit; + +namespace Bit.Core.Test.Billing.Organizations.Models; + +public class OrganizationSubscriptionChangeTests +{ + [Fact] + public void ImplicitConversion_AddItem_SetsCorrectFlags() + { + OrganizationSubscriptionChange change = new AddItem("price_123", 5); + + Assert.True(change.IsItemAddition); + Assert.False(change.IsItemPriceChange); + Assert.False(change.IsItemRemoval); + Assert.False(change.IsItemQuantityUpdate); + Assert.True(change.IsStructural); + } + + [Fact] + public void ImplicitConversion_ChangeItemPrice_SetsCorrectFlags() + { + OrganizationSubscriptionChange change = new ChangeItemPrice("price_old", "price_new", null); + + Assert.False(change.IsItemAddition); + Assert.True(change.IsItemPriceChange); + Assert.False(change.IsItemRemoval); + Assert.False(change.IsItemQuantityUpdate); + Assert.True(change.IsStructural); + } + + [Fact] + public void ImplicitConversion_RemoveItem_SetsCorrectFlags() + { + OrganizationSubscriptionChange change = new RemoveItem("price_123"); + + Assert.False(change.IsItemAddition); + Assert.False(change.IsItemPriceChange); + Assert.True(change.IsItemRemoval); + Assert.False(change.IsItemQuantityUpdate); + Assert.True(change.IsStructural); + } + + [Fact] + public void ImplicitConversion_UpdateItemQuantity_SetsCorrectFlags() + { + OrganizationSubscriptionChange change = new UpdateItemQuantity("price_123", 10); + + Assert.False(change.IsItemAddition); + Assert.False(change.IsItemPriceChange); + Assert.False(change.IsItemRemoval); + Assert.True(change.IsItemQuantityUpdate); + Assert.False(change.IsStructural); + } + + [Fact] + public void ImplicitConversion_UpdateItemQuantityToZero_IsStructural() + { + OrganizationSubscriptionChange change = new UpdateItemQuantity("price_123", 0); + + Assert.True(change.IsItemQuantityUpdate); + Assert.True(change.IsStructural); + } +} + +public class OrganizationSubscriptionChangeSetTests +{ + [Fact] + public void UpdatePasswordManagerSeats_CreatesCorrectChangeSet() + { + var plan = MockPlans.Get(PlanType.TeamsAnnually); + + var changeSet = OrganizationSubscriptionChangeSet.UpdatePasswordManagerSeats(plan, 25); + + var change = Assert.Single(changeSet.Changes); + Assert.True(change.IsItemQuantityUpdate); + Assert.False(change.IsStructural); + + var update = change.AsT3; + Assert.Equal(plan.PasswordManager.StripeSeatPlanId, update.PriceId); + Assert.Equal(25, update.Quantity); + } + + [Fact] + public void UpdateStorage_CreatesCorrectChangeSet() + { + var plan = MockPlans.Get(PlanType.TeamsAnnually); + + var changeSet = OrganizationSubscriptionChangeSet.UpdateStorage(plan, 3); + + var change = Assert.Single(changeSet.Changes); + Assert.True(change.IsItemQuantityUpdate); + + var update = change.AsT3; + Assert.Equal(plan.PasswordManager.StripeStoragePlanId, update.PriceId); + Assert.Equal(3, update.Quantity); + } + + [Fact] + public void UpdateSecretsManagerSeats_CreatesCorrectChangeSet() + { + var plan = MockPlans.Get(PlanType.TeamsAnnually); + + var changeSet = OrganizationSubscriptionChangeSet.UpdateSecretsManagerSeats(plan, 10); + + var change = Assert.Single(changeSet.Changes); + Assert.True(change.IsItemQuantityUpdate); + + var update = change.AsT3; + Assert.Equal(plan.SecretsManager.StripeSeatPlanId, update.PriceId); + Assert.Equal(10, update.Quantity); + } + + [Fact] + public void UpdateSecretsManagerServiceAccounts_CreatesCorrectChangeSet() + { + var plan = MockPlans.Get(PlanType.TeamsAnnually); + + var changeSet = OrganizationSubscriptionChangeSet.UpdateSecretsManagerServiceAccounts(plan, 50); + + var change = Assert.Single(changeSet.Changes); + Assert.True(change.IsItemQuantityUpdate); + + var update = change.AsT3; + Assert.Equal(plan.SecretsManager.StripeServiceAccountPlanId, update.PriceId); + Assert.Equal(50, update.Quantity); + } +} + +public class OrganizationSubscriptionChangeSetBuilderTests +{ + [Fact] + public void AddItem_AddsToChanges() + { + var changeSet = new OrganizationSubscriptionChangeSetBuilder() + .AddItem("price_add", 3) + .Build(); + + var change = Assert.Single(changeSet.Changes); + Assert.True(change.IsItemAddition); + + var item = change.AsT0; + Assert.Equal("price_add", item.PriceId); + Assert.Equal(3, item.Quantity); + } + + [Fact] + public void ChangeItemPrice_AddsToChanges() + { + var changeSet = new OrganizationSubscriptionChangeSetBuilder() + .ChangeItemPrice("price_old", "price_new") + .Build(); + + var change = Assert.Single(changeSet.Changes); + Assert.True(change.IsItemPriceChange); + + var item = change.AsT1; + Assert.Equal("price_old", item.CurrentPriceId); + Assert.Equal("price_new", item.UpdatedPriceId); + Assert.Null(item.Quantity); + } + + [Fact] + public void ChangeItemPrice_WithQuantity_AddsToChanges() + { + var changeSet = new OrganizationSubscriptionChangeSetBuilder() + .ChangeItemPrice("price_old", "price_new", 7) + .Build(); + + var change = Assert.Single(changeSet.Changes); + var item = change.AsT1; + Assert.Equal(7, item.Quantity); + } + + [Fact] + public void RemoveItem_AddsToChanges() + { + var changeSet = new OrganizationSubscriptionChangeSetBuilder() + .RemoveItem("price_remove") + .Build(); + + var change = Assert.Single(changeSet.Changes); + Assert.True(change.IsItemRemoval); + + var item = change.AsT2; + Assert.Equal("price_remove", item.PriceId); + } + + [Fact] + public void UpdateItemQuantity_AddsToChanges() + { + var changeSet = new OrganizationSubscriptionChangeSetBuilder() + .UpdateItemQuantity("price_qty", 15) + .Build(); + + var change = Assert.Single(changeSet.Changes); + Assert.True(change.IsItemQuantityUpdate); + + var item = change.AsT3; + Assert.Equal("price_qty", item.PriceId); + Assert.Equal(15, item.Quantity); + } + + [Fact] + public void Build_WithMultipleChanges_PreservesOrder() + { + var changeSet = new OrganizationSubscriptionChangeSetBuilder() + .AddItem("price_1", 1) + .RemoveItem("price_2") + .ChangeItemPrice("price_3", "price_4") + .UpdateItemQuantity("price_5", 10) + .Build(); + + Assert.Equal(4, changeSet.Changes.Count); + Assert.True(changeSet.Changes[0].IsItemAddition); + Assert.True(changeSet.Changes[1].IsItemRemoval); + Assert.True(changeSet.Changes[2].IsItemPriceChange); + Assert.True(changeSet.Changes[3].IsItemQuantityUpdate); + } + + [Fact] + public void Build_WithNoChanges_ReturnsEmptyChangeSet() + { + var changeSet = new OrganizationSubscriptionChangeSetBuilder() + .Build(); + + Assert.Empty(changeSet.Changes); + } + + [Fact] + public void Build_ReturnsReadOnlyChanges() + { + var changeSet = new OrganizationSubscriptionChangeSetBuilder() + .AddItem("price_1", 1) + .Build(); + + Assert.IsAssignableFrom>(changeSet.Changes); + } + + [Fact] + public void Build_MixedStructuralAndNonStructural() + { + var changeSet = new OrganizationSubscriptionChangeSetBuilder() + .AddItem("price_add", 1) + .UpdateItemQuantity("price_qty", 5) + .Build(); + + Assert.Equal(2, changeSet.Changes.Count); + Assert.True(changeSet.Changes[0].IsStructural); + Assert.False(changeSet.Changes[1].IsStructural); + } +} diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommandTests.cs index 127cc7e50257..01f1ae98891c 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/Cloud/SetUpSponsorshipCommandTests.cs @@ -1,14 +1,21 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Cloud; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Test.AutoFixture.OrganizationSponsorshipFixtures; +using Bit.Core.Test.Billing.Mocks; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; +using Stripe; using Xunit; namespace Bit.Core.Test.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Cloud; @@ -80,11 +87,82 @@ public async Task SetUpSponsorship_OrgNotFamilies_ThrowsBadRequest(PlanType plan await AssertDidNotSetUpAsync(sutProvider); } + [Theory] + [BitMemberAutoData(nameof(FamiliesPlanTypes))] + public async Task SetUpSponsorship_FeatureFlagOff_UsesSponsorOrganizationAsync(PlanType planType, + OrganizationSponsorship sponsorship, Organization org, + SutProvider sutProvider) + { + org.PlanType = planType; + sponsorship.LastSyncDate = DateTime.UtcNow; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(false); + + await sutProvider.Sut.SetUpSponsorshipAsync(sponsorship, org); + + await sutProvider.GetDependency() + .Received(1) + .SponsorOrganizationAsync(org, sponsorship); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .Run(default, default); + await AssertDidSetUpAsync(sutProvider, sponsorship, org); + } + + [Theory] + [BitMemberAutoData(nameof(FamiliesPlanTypes))] + public async Task SetUpSponsorship_FeatureFlagOn_UsesUpdateOrganizationSubscriptionCommand(PlanType planType, + OrganizationSponsorship sponsorship, Organization org, + SutProvider sutProvider) + { + org.PlanType = planType; + sponsorship.LastSyncDate = DateTime.UtcNow; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(true); + + var existingPlan = MockPlans.Get(planType); + sutProvider.GetDependency() + .GetPlanOrThrow(planType) + .Returns(existingPlan); + + var expectedPeriodEnd = DateTime.UtcNow.AddYears(1); + var subscription = new Subscription + { + Items = new StripeList + { + Data = [new SubscriptionItem { CurrentPeriodEnd = expectedPeriodEnd }] + } + }; + BillingCommandResult successResult = subscription; + sutProvider.GetDependency() + .Run(org, Arg.Any()) + .Returns(successResult); + + await sutProvider.Sut.SetUpSponsorshipAsync(sponsorship, org); + + await sutProvider.GetDependency() + .Received(1) + .Run(org, Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SponsorOrganizationAsync(default, default); + Assert.Equal(expectedPeriodEnd, org.ExpirationDate); + Assert.Equal(expectedPeriodEnd, sponsorship.ValidUntil); + await AssertDidSetUpAsync(sutProvider, sponsorship, org); + } + private static async Task AssertDidNotSetUpAsync(SutProvider sutProvider) { await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SponsorOrganizationAsync(default, default); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .Run(default, default); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .UpsertAsync(default); @@ -92,4 +170,17 @@ await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .UpsertAsync(default); } + + private static async Task AssertDidSetUpAsync(SutProvider sutProvider, + OrganizationSponsorship sponsorship, Organization org) + { + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(org); + Assert.Equal(org.Id, sponsorship.SponsoredOrganizationId); + Assert.Null(sponsorship.OfferedToEmail); + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(sponsorship); + } } diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs index 510433a2faef..3141bfe70b14 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs @@ -1,5 +1,8 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -17,6 +20,7 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; +using Subscription = Stripe.Subscription; namespace Bit.Core.Test.OrganizationFeatures.OrganizationSubscriptionUpdate; @@ -778,6 +782,172 @@ public async Task UpdateMaxAutoscaleSmServiceAccounts_ThrowsBadRequestException_ await VerifyDependencyNotCalledAsync(sutProvider); } + [Theory] + [BitMemberAutoData(nameof(AllTeamsAndEnterprise))] + public async Task UpdateSubscriptionAsync_WithFeatureFlag_AboveBaseServiceAccounts_UpdatesItemQuantity( + Plan plan, + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = plan.Type; + organization.Seats = 400; + organization.SmSeats = 10; + organization.MaxAutoscaleSmSeats = 20; + organization.SmServiceAccounts = plan.SecretsManager.BaseServiceAccount + 10; + organization.MaxAutoscaleSmServiceAccounts = 350; + + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) + { + SmSeats = 15, + SmServiceAccounts = plan.SecretsManager.BaseServiceAccount + 20, + MaxAutoscaleSmSeats = 16, + MaxAutoscaleSmServiceAccounts = 351 + }; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(true); + + BillingCommandResult successResult = new Subscription(); + sutProvider.GetDependency() + .Run(organization, Arg.Any()) + .Returns(successResult); + + await sutProvider.Sut.UpdateSubscriptionAsync(update); + + await sutProvider.GetDependency().Received(1) + .Run(organization, Arg.Is(cs => + cs.Changes.Count == 2 && + cs.Changes[0].IsItemQuantityUpdate && + cs.Changes[1].IsItemQuantityUpdate)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AdjustSmSeatsAsync(default, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AdjustServiceAccountsAsync(default, default, default); + } + + [Theory] + [BitMemberAutoData(nameof(AllTeamsAndEnterprise))] + public async Task UpdateSubscriptionAsync_WithFeatureFlag_AtBaseServiceAccounts_AddsItem( + Plan plan, + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = plan.Type; + organization.Seats = 400; + organization.SmSeats = 10; + organization.MaxAutoscaleSmSeats = 20; + organization.SmServiceAccounts = plan.SecretsManager.BaseServiceAccount; + organization.MaxAutoscaleSmServiceAccounts = 350; + + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) + { + SmSeats = 15, + SmServiceAccounts = plan.SecretsManager.BaseServiceAccount + 10, + MaxAutoscaleSmSeats = 16, + MaxAutoscaleSmServiceAccounts = 351 + }; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(true); + + BillingCommandResult successResult = new Subscription(); + sutProvider.GetDependency() + .Run(organization, Arg.Any()) + .Returns(successResult); + + await sutProvider.Sut.UpdateSubscriptionAsync(update); + + await sutProvider.GetDependency().Received(1) + .Run(organization, Arg.Is(cs => + cs.Changes.Count == 2 && + cs.Changes[0].IsItemQuantityUpdate && + cs.Changes[1].IsItemAddition)); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AdjustSmSeatsAsync(default, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AdjustServiceAccountsAsync(default, default, default); + } + + [Theory] + [BitMemberAutoData(nameof(AllTeamsAndEnterprise))] + public async Task UpdateSubscriptionAsync_WithFeatureFlag_OnlyAutoscaleLimitsChanged_SkipsCommand( + Plan plan, + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = plan.Type; + organization.Seats = 400; + organization.SmSeats = 10; + organization.MaxAutoscaleSmSeats = 20; + organization.SmServiceAccounts = 200; + organization.MaxAutoscaleSmServiceAccounts = 350; + + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) + { + SmSeats = organization.SmSeats, + SmServiceAccounts = organization.SmServiceAccounts, + MaxAutoscaleSmSeats = 25, + MaxAutoscaleSmServiceAccounts = 400 + }; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(true); + + await sutProvider.Sut.UpdateSubscriptionAsync(update); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .Run(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AdjustSmSeatsAsync(default, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .AdjustServiceAccountsAsync(default, default, default); + + AssertUpdatedOrganization(() => Arg.Is(org => + org.Id == organization.Id && + org.MaxAutoscaleSmSeats == 25 && + org.MaxAutoscaleSmServiceAccounts == 400), + sutProvider); + } + + [Theory] + [BitMemberAutoData(nameof(AllTeamsAndEnterprise))] + public async Task UpdateSubscriptionAsync_WithoutFeatureFlag_UpdateSeatsAndServiceAccounts_UsesPaymentService( + Plan plan, + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = plan.Type; + organization.Seats = 400; + organization.SmSeats = 10; + organization.MaxAutoscaleSmSeats = 20; + organization.SmServiceAccounts = 200; + organization.MaxAutoscaleSmServiceAccounts = 350; + + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) + { + SmSeats = 15, + SmServiceAccounts = 300, + MaxAutoscaleSmSeats = 16, + MaxAutoscaleSmServiceAccounts = 301 + }; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(false); + + await sutProvider.Sut.UpdateSubscriptionAsync(update); + + await sutProvider.GetDependency().Received(1) + .AdjustSmSeatsAsync(organization, plan, update.SmSeatsExcludingBase); + await sutProvider.GetDependency().Received(1) + .AdjustServiceAccountsAsync(organization, plan, update.SmServiceAccountsExcludingBase); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .Run(default, default); + } + private static async Task VerifyDependencyNotCalledAsync(SutProvider sutProvider) { await sutProvider.GetDependency().DidNotReceive() diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs index 87ab30e7798b..59f873b4fdb5 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs @@ -2,7 +2,10 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.Billing; +using Bit.Core.Billing.Commands; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -21,6 +24,7 @@ using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; +using OneOf.Types; using Xunit; namespace Bit.Core.Test.OrganizationFeatures.OrganizationSubscriptionUpdate; @@ -389,6 +393,79 @@ await sutProvider.GetDependency() .ReplaceAndUpdateCacheAsync(organization); } + [Theory, BitAutoData] + public async Task UpgradePlan_FeatureFlagOn_OrganizationIsNull_Throws( + Guid organizationId, + OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(organizationId) + .Returns(Task.FromResult(null)); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(true); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpgradePlanAsync(organizationId, upgrade)); + } + + [Theory, BitAutoData] + public async Task UpgradePlan_FeatureFlagOn_DelegatesToVNextCommand( + Organization organization, + OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(true); + sutProvider.GetDependency() + .GetPlanOrThrow(upgrade.Plan) + .Returns(MockPlans.Get(upgrade.Plan)); + + BillingCommandResult successResult = new None(); + sutProvider.GetDependency() + .Run(organization, MockPlans.Get(upgrade.Plan), upgrade.Keys) + .Returns(successResult); + + var result = await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade); + + Assert.True(result.Item1); + Assert.Null(result.Item2); + await sutProvider.GetDependency() + .Received(1) + .Run(organization, MockPlans.Get(upgrade.Plan), upgrade.Keys); + } + + [Theory, BitAutoData] + public async Task UpgradePlan_FeatureFlagOn_VNextFailure_ThrowsBillingException( + Organization organization, + OrganizationUpgrade upgrade, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM32581_UseUpdateOrganizationSubscriptionCommand) + .Returns(true); + sutProvider.GetDependency() + .GetPlanOrThrow(upgrade.Plan) + .Returns(MockPlans.Get(upgrade.Plan)); + + BillingCommandResult failureResult = new BadRequest("Something went wrong"); + sutProvider.GetDependency() + .Run(organization, MockPlans.Get(upgrade.Plan), upgrade.Keys) + .Returns(failureResult); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade)); + Assert.Equal("Something went wrong", exception.Response); + } + [Theory] [FreeOrganizationUpgradeCustomize, BitAutoData] public async Task UpgradePlan_WhenOrganizationAlreadyHasPublicAndPrivateKeys_DoesNotBackfillWithNewKeys( diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index d6407a80581e..24c637459ca0 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -13,6 +13,7 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Premium.Queries; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -631,6 +632,50 @@ public async Task AdminResetPasswordAsync_EmptyOrWhitespaceResetPasswordKey_Thro OrganizationUserType.Owner, organization.Id, orgUser.Id, "newPassword", "key")); Assert.Equal("Organization User not valid", exception.Message); } + + [Theory, BitAutoData] + public async Task AdjustStorageAsync_NullUser_ThrowsArgumentNullException( + SutProvider sutProvider) + { + await Assert.ThrowsAsync( + () => sutProvider.Sut.AdjustStorageAsync(null, 1)); + } + + [Theory, BitAutoData] + public async Task AdjustStorageAsync_NotPremium_ThrowsBadRequestException( + User user, SutProvider sutProvider) + { + user.Premium = false; + + await Assert.ThrowsAsync( + () => sutProvider.Sut.AdjustStorageAsync(user, 1)); + } + + [Theory, BitAutoData] + public async Task AdjustStorageAsync_Success_CallsPaymentServiceAndSavesUser( + User user, SutProvider sutProvider) + { + user.Premium = true; + user.GatewayCustomerId = "cus_123"; + user.GatewaySubscriptionId = "sub_123"; + user.MaxStorageGb = 1; + user.Storage = 0; + + var premiumPlan = new Bit.Core.Billing.Pricing.Premium.Plan + { + Name = "Premium", + Available = true, + Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable { StripePriceId = "premium-seat", Price = 10, Provided = 1 }, + Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable { StripePriceId = "storage-gb-annually", Price = 4, Provided = 1 } + }; + + sutProvider.GetDependency().GetAvailablePremiumPlan().Returns(premiumPlan); + + await sutProvider.Sut.AdjustStorageAsync(user, 1); + + await sutProvider.GetDependency().Received(1) + .AdjustStorageAsync(user, Arg.Any(), premiumPlan.Storage.StripePriceId); + } } public static class UserServiceSutProviderExtensions @@ -683,8 +728,6 @@ private static SutProvider SetFakeTokenProvider(this SutProvider /// Properly registers IUserPasswordStore as IUserStore so it's injected when the sut is initialized. /// - /// - /// private static SutProvider SetUserPasswordStore(this SutProvider sutProvider) { var substitutedUserPasswordStore = Substitute.For>(); From 6231dd22d4bb8125f4ac96bfc4c96165efab40c4 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Mon, 9 Mar 2026 16:43:50 -0400 Subject: [PATCH 45/85] remove flagged logic (#7179) --- .../SendOrganizationInvitesCommand.cs | 20 ++------ .../SendOrganizationInvitesCommandTests.cs | 50 ++----------------- 2 files changed, 8 insertions(+), 62 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs index 30fdb7c85a9a..f97303ac87c0 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -22,24 +22,14 @@ public class SendOrganizationInvitesCommand( IPolicyQuery policyQuery, IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, IDataProtectorTokenFactory dataProtectorTokenFactory, - IMailService mailService, - IFeatureService featureService) : ISendOrganizationInvitesCommand + IMailService mailService) : ISendOrganizationInvitesCommand { public async Task SendInvitesAsync(SendInvitesRequest request) { - if (featureService.IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate)) - { - var inviterEmail = await GetInviterEmailAsync(request.InvitingUserId); - var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync( - request.Users, request.Organization, request.InitOrganization, inviterEmail); - await mailService.SendUpdatedOrganizationInviteEmailsAsync(orgInvitesInfo); - } - else - { - var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync( - request.Users, request.Organization, request.InitOrganization); - await mailService.SendOrganizationInviteEmailsAsync(orgInvitesInfo); - } + var inviterEmail = await GetInviterEmailAsync(request.InvitingUserId); + var orgInvitesInfo = await BuildOrganizationInvitesInfoAsync( + request.Users, request.Organization, request.InitOrganization, inviterEmail); + await mailService.SendUpdatedOrganizationInviteEmailsAsync(orgInvitesInfo); } private async Task BuildOrganizationInvitesInfoAsync(IEnumerable orgUsers, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs index 60aaa6664a00..63aa39a74e65 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommandTests.cs @@ -68,7 +68,7 @@ public async Task SendInvitesAsync_SsoOrgWithNeverEnabledRequireSsoPolicy_SendsE // Assert await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => + .SendUpdatedOrganizationInviteEmailsAsync(Arg.Is(info => info.OrgUserTokenPairs.Count() == 1 && info.OrgUserTokenPairs.FirstOrDefault(x => x.OrgUser.Email == invite.Email).OrgUser == invite && info.IsFreeOrg == (organization.PlanType == PlanType.Free) && @@ -106,7 +106,7 @@ public async Task InviteUsers_SsoOrgWithNullSsoConfig_SendsInvite( await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization)); await sutProvider.GetDependency().Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Is(info => + .SendUpdatedOrganizationInviteEmailsAsync(Arg.Is(info => info.OrgUserTokenPairs.Count() == 1 && info.OrgUserTokenPairs.FirstOrDefault(x => x.OrgUser.Email == invite.Email).OrgUser == invite && info.IsFreeOrg == (organization.PlanType == PlanType.Free) && @@ -119,7 +119,7 @@ await sutProvider.GetDependency().Received(1) [BitAutoData(PlanType.FamiliesAnnually)] [BitAutoData(PlanType.Free)] [BitAutoData(PlanType.Custom)] - public async Task SendInvitesAsync_WithFeatureFlagEnabled_CallsMailServiceWithNewTemplates( + public async Task SendInvitesAsync_CallsMailServiceWithNewTemplates( PlanType planType, Organization organization, OrganizationUser invite, @@ -132,10 +132,6 @@ public async Task SendInvitesAsync_WithFeatureFlagEnabled_CallsMailServiceWithNe organization.PlanType = planType; invite.OrganizationId = organization.Id; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate) - .Returns(true); - sutProvider.GetDependency() .GetManyByEmailsAsync(Arg.Any>()) .Returns([]); @@ -161,37 +157,6 @@ await sutProvider.GetDependency().Received(1) info.InviterEmail == invitingUser.Email)); } - [Theory, BitAutoData] - public async Task SendInvitesAsync_WithFeatureFlagDisabled_UsesLegacyMailService( - Organization organization, - OrganizationUser invite, - SutProvider sutProvider) - { - // Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks - sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory"); - sutProvider.Create(); - - // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate) - .Returns(false); - - sutProvider.GetDependency() - .CreateToken(Arg.Any()) - .Returns(info => new OrgUserInviteTokenable(info.Arg()) - { - ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5)) - }); - - // Act - await sutProvider.Sut.SendInvitesAsync(new SendInvitesRequest([invite], organization)); - - // Assert - verify legacy mail service is called, not new mailer - await sutProvider.GetDependency() - .Received(1) - .SendOrganizationInviteEmailsAsync(Arg.Any()); - } - [Theory, BitAutoData] public async Task SendInvitesAsync_WithInvitingUserId_PopulatesInviterEmail( Organization organization, @@ -203,9 +168,6 @@ public async Task SendInvitesAsync_WithInvitingUserId_PopulatesInviterEmail( // Arrange organization.PlanType = PlanType.EnterpriseAnnually; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate) - .Returns(true); sutProvider.GetDependency() .GetManyByEmailsAsync(Arg.Any>()) @@ -242,9 +204,6 @@ public async Task SendInvitesAsync_WithNullInvitingUserId_SendsEmailWithoutInvit // Arrange organization.PlanType = PlanType.EnterpriseAnnually; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate) - .Returns(true); sutProvider.GetDependency() .GetManyByEmailsAsync(Arg.Any>()) @@ -278,9 +237,6 @@ public async Task SendInvitesAsync_WithNonExistentInvitingUserId_SendsEmailWitho // Arrange organization.PlanType = PlanType.EnterpriseAnnually; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.UpdateJoinOrganizationEmailTemplate) - .Returns(true); sutProvider.GetDependency() .GetManyByEmailsAsync(Arg.Any>()) From 26a4a5df2a183eb16db4dcda174f36d752da0595 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:15:41 +1000 Subject: [PATCH 46/85] Update UseMyItems to use dedicated plan feature (#7101) --- .../Billing/Providers/Services/BusinessUnitConverter.cs | 2 +- .../Billing/Providers/Services/ProviderBillingService.cs | 2 +- src/Admin/AdminConsole/Models/OrganizationEditModel.cs | 1 + .../AdminConsole/Views/Shared/_OrganizationFormScripts.cshtml | 2 +- .../Organizations/CloudOrganizationSignUpCommand.cs | 2 +- .../Organizations/ProviderClientOrganizationSignUpCommand.cs | 2 +- .../Premium/Commands/UpgradePremiumToOrganizationCommand.cs | 2 +- .../Subscriptions/Commands/RestartSubscriptionCommand.cs | 2 +- .../OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs | 2 +- 9 files changed, 9 insertions(+), 8 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs index 07420c895e81..5fa2c9405c45 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs @@ -65,7 +65,7 @@ public async Task FinalizeConversion( organization.MaxCollections = updatedPlan.PasswordManager.MaxCollections; organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb; organization.UsePolicies = updatedPlan.HasPolicies; - organization.UseMyItems = updatedPlan.HasPolicies; // TODO: use the plan property when added (PM-32366) + organization.UseMyItems = updatedPlan.HasMyItems; organization.UseSso = updatedPlan.HasSso; organization.UseOrganizationDomains = updatedPlan.HasOrganizationDomains; organization.UseGroups = updatedPlan.HasGroups; diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index 19ee33c68260..e196e0f989c6 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -91,7 +91,7 @@ await stripeAdapter.FinalizeInvoiceAsync(subscription.LatestInvoiceId, organization.MaxCollections = plan.PasswordManager.MaxCollections; organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb; organization.UsePolicies = plan.HasPolicies; - organization.UseMyItems = plan.HasPolicies; // TODO: use the plan property when added (PM-32366) + organization.UseMyItems = plan.HasMyItems; organization.UseSso = plan.HasSso; organization.UseOrganizationDomains = plan.HasOrganizationDomains; organization.UseGroups = plan.HasGroups; diff --git a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs index 644339e34b8d..98935f0f600c 100644 --- a/src/Admin/AdminConsole/Models/OrganizationEditModel.cs +++ b/src/Admin/AdminConsole/Models/OrganizationEditModel.cs @@ -239,6 +239,7 @@ public object GetPlansHelper() => HasResetPassword = p.HasResetPassword, UsersGetPremium = p.UsersGetPremium, HasCustomPermissions = p.HasCustomPermissions, + HasMyItems = p.HasMyItems, UpgradeSortOrder = p.UpgradeSortOrder, DisplaySortOrder = p.DisplaySortOrder, LegacyYear = p.LegacyYear, diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationFormScripts.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationFormScripts.cshtml index f8f3c9f6e6e6..85af4f3199a8 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationFormScripts.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationFormScripts.cshtml @@ -80,7 +80,7 @@ // Password Manager features document.getElementById('@(nameof(Model.UseTotp))').checked = plan.hasTotp; document.getElementById('@(nameof(Model.UsersGetPremium))').checked = plan.usersGetPremium; - document.getElementById('@(nameof(Model.UseMyItems))').checked = plan.hasPolicies; // TODO: use the plan property when added (PM-32366) + document.getElementById('@(nameof(Model.UseMyItems))').checked = plan.hasMyItems; document.getElementById('@(nameof(Model.MaxStorageGb))').value = document.getElementById('@(nameof(Model.MaxStorageGb))').value || diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index 275a98853245..8fb98ee7dd0a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -79,7 +79,7 @@ public async Task SignUpOrganizationAsync(Organizati MaxCollections = plan.PasswordManager.MaxCollections, MaxStorageGb = (short)(plan.PasswordManager.BaseStorageGb + signup.AdditionalStorageGb), UsePolicies = plan.HasPolicies, - UseMyItems = plan.HasPolicies, // TODO: use the plan property when added (PM-32366) + UseMyItems = plan.HasMyItems, UseSso = plan.HasSso, UseGroups = plan.HasGroups, UseEvents = plan.HasEvents, diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs index f84e19772065..3fd71094ccb6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs @@ -75,7 +75,7 @@ public async Task SignUpClientOrganiza MaxCollections = plan.PasswordManager.MaxCollections, MaxStorageGb = plan.PasswordManager.BaseStorageGb, UsePolicies = plan.HasPolicies, - UseMyItems = plan.HasPolicies, // TODO: use the plan property when added (PM-32366) + UseMyItems = plan.HasMyItems, UseSso = plan.HasSso, UseOrganizationDomains = plan.HasOrganizationDomains, UseGroups = plan.HasGroups, diff --git a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs index a7bf3e20cea5..8e49ecd8b565 100644 --- a/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs +++ b/src/Core/Billing/Premium/Commands/UpgradePremiumToOrganizationCommand.cs @@ -165,7 +165,7 @@ public Task> Run( MaxCollections = targetPlan.PasswordManager.MaxCollections, MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb, UsePolicies = targetPlan.HasPolicies, - UseMyItems = targetPlan.HasPolicies, // TODO: use the plan property when added (PM-32366) + UseMyItems = targetPlan.HasMyItems, UseSso = targetPlan.HasSso, UseGroups = targetPlan.HasGroups, UseEvents = targetPlan.HasEvents, diff --git a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs index cc13a48ca075..228362052748 100644 --- a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs @@ -82,7 +82,7 @@ private async Task RestartOrganizationSubscriptionAsync( organization.Plan = newPlan.Name; organization.SelfHost = newPlan.HasSelfHost; organization.UsePolicies = newPlan.HasPolicies; - organization.UseMyItems = newPlan.HasPolicies; // TODO: use the plan property when added (PM-32366) + organization.UseMyItems = newPlan.HasMyItems; organization.UseGroups = newPlan.HasGroups; organization.UseDirectory = newPlan.HasDirectory; organization.UseEvents = newPlan.HasEvents; diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 212c9b505a67..3b33632dec6b 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -295,7 +295,7 @@ await _organizationUserRepository.GetManyByOrganizationAsync(organization.Id, organization.UseApi = newPlan.HasApi; organization.SelfHost = newPlan.HasSelfHost; organization.UsePolicies = newPlan.HasPolicies; - organization.UseMyItems = newPlan.HasPolicies; // TODO: use the plan property when added (PM-32366) + organization.UseMyItems = newPlan.HasMyItems; organization.MaxStorageGb = (short)(newPlan.PasswordManager.BaseStorageGb + upgrade.AdditionalStorageGb); organization.UseSso = newPlan.HasSso; organization.UseOrganizationDomains = newPlan.HasOrganizationDomains; From a4072964dd1947be445cb0de6c5dfe4a9d4cf187 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Tue, 10 Mar 2026 06:16:10 +0100 Subject: [PATCH 47/85] Reorganize seeder presets into purpose-based folders and remove obsolete presets (#7176) --- util/Seeder/Seeds/README.md | 66 +++----- .../{ => features}/policy-enterprise.json | 2 +- .../{ => features}/sso-enterprise.json | 2 +- .../{ => features}/tde-enterprise.json | 2 +- .../fixtures/presets/large-enterprise.json | 21 --- .../collection-permissions-enterprise.json | 2 +- .../dunder-mifflin-enterprise-full.json | 2 +- .../presets/{ => qa}/enterprise-basic.json | 2 +- .../presets/{ => qa}/families-basic.json | 2 +- .../presets/{ => qa}/stark-free-basic.json | 2 +- .../fixtures/presets/validation/README.md | 153 ------------------ .../fixtures/presets/wonka-teams-small.json | 26 --- 12 files changed, 32 insertions(+), 250 deletions(-) rename util/Seeder/Seeds/fixtures/presets/{ => features}/policy-enterprise.json (82%) rename util/Seeder/Seeds/fixtures/presets/{ => features}/sso-enterprise.json (87%) rename util/Seeder/Seeds/fixtures/presets/{ => features}/tde-enterprise.json (87%) delete mode 100644 util/Seeder/Seeds/fixtures/presets/large-enterprise.json rename util/Seeder/Seeds/fixtures/presets/{ => qa}/collection-permissions-enterprise.json (82%) rename util/Seeder/Seeds/fixtures/presets/{ => qa}/dunder-mifflin-enterprise-full.json (80%) rename util/Seeder/Seeds/fixtures/presets/{ => qa}/enterprise-basic.json (81%) rename util/Seeder/Seeds/fixtures/presets/{ => qa}/families-basic.json (84%) rename util/Seeder/Seeds/fixtures/presets/{ => qa}/stark-free-basic.json (86%) delete mode 100644 util/Seeder/Seeds/fixtures/presets/validation/README.md delete mode 100644 util/Seeder/Seeds/fixtures/presets/wonka-teams-small.json diff --git a/util/Seeder/Seeds/README.md b/util/Seeder/Seeds/README.md index e9c72eef2e55..9338281b18bf 100644 --- a/util/Seeder/Seeds/README.md +++ b/util/Seeder/Seeds/README.md @@ -1,13 +1,23 @@ # Seeds -Hand-crafted JSON fixtures for Bitwarden Seeder test data. +Hand-crafted JSON fixtures and preset configurations for Bitwarden Seeder test data. ## Quick Start -1. Create a JSON file in the right `fixtures/` subfolder -2. Add the `$schema` line — your editor picks up validation automatically +1. Pick a preset from the catalog below +2. Run: `dotnet run -- seed --preset {name} --mangle` 3. Build to verify: `dotnet build util/Seeder/Seeder.csproj` +## Presets + +Presets wire everything together: org + roster + ciphers. Organized by purpose: + +| Folder | Purpose | CLI prefix | Example | +|--------|---------|------------|---------| +| `features/` | Test specific Bitwarden features (SSO, TDE, policies) | `features.` | `--preset features.sso-enterprise` | +| `qa/` | Handcrafted fixture data for visual UI verification | `qa.` | `--preset qa.enterprise-basic` | +| `validation/` | Algorithm verification for seeder development | `validation.` | `--preset validation.density-modeling-power-law-test` | + ## Writing Fixtures ### Organizations @@ -43,18 +53,6 @@ Vault items. Each item needs a `type` and `name`. See: `fixtures/ciphers/enterprise-basic.json` -### Presets - -Presets **wire everything together**: org + roster + ciphers. You can reference fixtures by name or generate data with counts. - -Three styles: - -- **Fixture-based**: `enterprise-basic.json` — references org, roster, and cipher fixtures -- **Generated**: `wonka-teams-small.json` — uses `count` parameters to create users, groups, collections, ciphers -- **Feature-specific**: `tde-enterprise.json`, `policy-enterprise.json` — adds SSO config, policies - -Presets can also define inline orgs (name + domain right in the preset) instead of referencing a fixture — see `large-enterprise.json`. - ## Naming Conventions | Element | Pattern | Example | @@ -72,36 +70,20 @@ Your editor validates against `$schema` automatically — errors show up as red dotnet build util/Seeder/Seeder.csproj ``` -## QA Test Fixture Migration Matrix - -These Seeds consolidate test data previously found across the `bitwarden/test` repo. -The table below maps existing QA fixtures to their Seeder equivalents. - -| QA Source (`test/Bitwarden.Web.Tests/TestData/SetupData/`) | Used By | Seeder Preset | Org Fixture | Roster Fixture | Cipher Fixture | -| ---------------------------------------------------------- | --------------------------------- | ----------------------------------- | ------------------- | ------------------------ | ------------------------ | -| `CollectionPermissionsOrg.json` | Web, Extension | `collection-permissions-enterprise` | `cobalt-logistics` | `collection-permissions` | `collection-permissions` | -| `EnterpriseOrg.json` | Web, Extension, Android, iOS, CLI | `enterprise-basic` | `redwood-analytics` | `enterprise-basic` | `enterprise-basic` | -| `SsoOrg.json` | Web | `sso-enterprise` | `verdant-health` | `starter-team` | `sso-vault` | -| `TDEOrg.json` | Web, Extension, Android, iOS | `tde-enterprise` | `obsidian-labs` | `starter-team` | `tde-vault` | -| _(Confluence: Policy Org guide)_ | QA manual setup | `policy-enterprise` | `pinnacle-designs` | `starter-team` | — | -| `FamiliesOrg.json` | Web, Extension | `families-basic` | `adams-family` | `family` | — | - -### Not Yet Migrated +## QA Migration -| QA Source | Used By | Status | -| --------------------- | ---------------------------- | --------------------------------------------------------------------- | -| `FreeAccount.json` | All 7 platforms | Planned — `free-personal-vault` preset (separate PR due to file size) | -| `PremiumAccount.json` | Web, Extension, Android, iOS | Planned — `premium-personal-vault` preset | -| `SecretsManager.json` | Web | Planned — `secrets-manager-enterprise` preset | -| `FreeOrg.json` | Web | Planned — `free-org-basic` preset | +Mapping from legacy QA test fixtures to seeder presets: -### Additional Sources +| Legacy Source | Seeder Preset | +|--------------|---------------| +| `CollectionPermissionsOrg.json` | `qa.collection-permissions-enterprise` | +| `EnterpriseOrg.json` | `qa.enterprise-basic` | +| `SsoOrg.json` | `features.sso-enterprise` | +| `TDEOrg.json` | `features.tde-enterprise` | +| Policy Org (Confluence) | `features.policy-enterprise` | +| `FamiliesOrg.json` | `qa.families-basic` | -| Source | Location | Status | -| ---------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `bw_importer.py` | `github.com/bitwarden/qa-tools` | Superseded by generation-based presets (`"ciphers": {"count": N}`) | -| `mass_org_manager.py` | `github.com/bitwarden/qa-tools` | Superseded by roster fixtures with groups/members/collections | -| Admin Console Testing Setup guides | Confluence QA space | Codified as `collection-permissions-enterprise`, `policy-enterprise`, `sso-enterprise`, `tde-enterprise` presets | +**Planned:** `qa.free-personal-vault`, `qa.premium-personal-vault`, `features.secrets-manager-enterprise`, `qa.free-org-basic` ## Security diff --git a/util/Seeder/Seeds/fixtures/presets/policy-enterprise.json b/util/Seeder/Seeds/fixtures/presets/features/policy-enterprise.json similarity index 82% rename from util/Seeder/Seeds/fixtures/presets/policy-enterprise.json rename to util/Seeder/Seeds/fixtures/presets/features/policy-enterprise.json index 7177d7312d6b..dc32fa637f65 100644 --- a/util/Seeder/Seeds/fixtures/presets/policy-enterprise.json +++ b/util/Seeder/Seeds/fixtures/presets/features/policy-enterprise.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/preset.schema.json", + "$schema": "../../../schemas/preset.schema.json", "organization": { "fixture": "pinnacle-designs", "planType": "enterprise-annually" diff --git a/util/Seeder/Seeds/fixtures/presets/sso-enterprise.json b/util/Seeder/Seeds/fixtures/presets/features/sso-enterprise.json similarity index 87% rename from util/Seeder/Seeds/fixtures/presets/sso-enterprise.json rename to util/Seeder/Seeds/fixtures/presets/features/sso-enterprise.json index 7c8c041c2b65..ee64d55d19a8 100644 --- a/util/Seeder/Seeds/fixtures/presets/sso-enterprise.json +++ b/util/Seeder/Seeds/fixtures/presets/features/sso-enterprise.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/preset.schema.json", + "$schema": "../../../schemas/preset.schema.json", "organization": { "fixture": "verdant-health", "planType": "enterprise-annually", diff --git a/util/Seeder/Seeds/fixtures/presets/tde-enterprise.json b/util/Seeder/Seeds/fixtures/presets/features/tde-enterprise.json similarity index 87% rename from util/Seeder/Seeds/fixtures/presets/tde-enterprise.json rename to util/Seeder/Seeds/fixtures/presets/features/tde-enterprise.json index 7fe9e75fbe7c..426f8c15c912 100644 --- a/util/Seeder/Seeds/fixtures/presets/tde-enterprise.json +++ b/util/Seeder/Seeds/fixtures/presets/features/tde-enterprise.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/preset.schema.json", + "$schema": "../../../schemas/preset.schema.json", "organization": { "fixture": "obsidian-labs", "planType": "enterprise-annually", diff --git a/util/Seeder/Seeds/fixtures/presets/large-enterprise.json b/util/Seeder/Seeds/fixtures/presets/large-enterprise.json deleted file mode 100644 index 7adc49a0588b..000000000000 --- a/util/Seeder/Seeds/fixtures/presets/large-enterprise.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "../../schemas/preset.schema.json", - "organization": { - "name": "Globex Corp", - "domain": "globex.example", - "seats": 10000 - }, - "users": { - "count": 5000, - "realisticStatusMix": true - }, - "groups": { - "count": 100 - }, - "collections": { - "count": 200 - }, - "ciphers": { - "count": 50000 - } -} diff --git a/util/Seeder/Seeds/fixtures/presets/collection-permissions-enterprise.json b/util/Seeder/Seeds/fixtures/presets/qa/collection-permissions-enterprise.json similarity index 82% rename from util/Seeder/Seeds/fixtures/presets/collection-permissions-enterprise.json rename to util/Seeder/Seeds/fixtures/presets/qa/collection-permissions-enterprise.json index d3866ea5cbb5..dbfd3a16e0d4 100644 --- a/util/Seeder/Seeds/fixtures/presets/collection-permissions-enterprise.json +++ b/util/Seeder/Seeds/fixtures/presets/qa/collection-permissions-enterprise.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/preset.schema.json", + "$schema": "../../../schemas/preset.schema.json", "organization": { "fixture": "cobalt-logistics", "planType": "enterprise-annually", diff --git a/util/Seeder/Seeds/fixtures/presets/dunder-mifflin-enterprise-full.json b/util/Seeder/Seeds/fixtures/presets/qa/dunder-mifflin-enterprise-full.json similarity index 80% rename from util/Seeder/Seeds/fixtures/presets/dunder-mifflin-enterprise-full.json rename to util/Seeder/Seeds/fixtures/presets/qa/dunder-mifflin-enterprise-full.json index 7e0b8f93d70c..908dcec17e9d 100644 --- a/util/Seeder/Seeds/fixtures/presets/dunder-mifflin-enterprise-full.json +++ b/util/Seeder/Seeds/fixtures/presets/qa/dunder-mifflin-enterprise-full.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/preset.schema.json", + "$schema": "../../../schemas/preset.schema.json", "organization": { "fixture": "dunder-mifflin", "planType": "enterprise-annually", diff --git a/util/Seeder/Seeds/fixtures/presets/enterprise-basic.json b/util/Seeder/Seeds/fixtures/presets/qa/enterprise-basic.json similarity index 81% rename from util/Seeder/Seeds/fixtures/presets/enterprise-basic.json rename to util/Seeder/Seeds/fixtures/presets/qa/enterprise-basic.json index d8102e7b83ca..62e8925b5bba 100644 --- a/util/Seeder/Seeds/fixtures/presets/enterprise-basic.json +++ b/util/Seeder/Seeds/fixtures/presets/qa/enterprise-basic.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/preset.schema.json", + "$schema": "../../../schemas/preset.schema.json", "organization": { "fixture": "redwood-analytics", "planType": "enterprise-annually", diff --git a/util/Seeder/Seeds/fixtures/presets/families-basic.json b/util/Seeder/Seeds/fixtures/presets/qa/families-basic.json similarity index 84% rename from util/Seeder/Seeds/fixtures/presets/families-basic.json rename to util/Seeder/Seeds/fixtures/presets/qa/families-basic.json index 0e90d72990aa..35aa3c803a50 100644 --- a/util/Seeder/Seeds/fixtures/presets/families-basic.json +++ b/util/Seeder/Seeds/fixtures/presets/qa/families-basic.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/preset.schema.json", + "$schema": "../../../schemas/preset.schema.json", "organization": { "fixture": "adams-family", "planType": "families-annually", diff --git a/util/Seeder/Seeds/fixtures/presets/stark-free-basic.json b/util/Seeder/Seeds/fixtures/presets/qa/stark-free-basic.json similarity index 86% rename from util/Seeder/Seeds/fixtures/presets/stark-free-basic.json rename to util/Seeder/Seeds/fixtures/presets/qa/stark-free-basic.json index dbbf9491931c..5456efe0330d 100644 --- a/util/Seeder/Seeds/fixtures/presets/stark-free-basic.json +++ b/util/Seeder/Seeds/fixtures/presets/qa/stark-free-basic.json @@ -1,5 +1,5 @@ { - "$schema": "../../schemas/preset.schema.json", + "$schema": "../../../schemas/preset.schema.json", "organization": { "fixture": "stark-industries", "planType": "free", diff --git a/util/Seeder/Seeds/fixtures/presets/validation/README.md b/util/Seeder/Seeds/fixtures/presets/validation/README.md deleted file mode 100644 index b4693febcc41..000000000000 --- a/util/Seeder/Seeds/fixtures/presets/validation/README.md +++ /dev/null @@ -1,153 +0,0 @@ -# Density Modeling Validation Presets - -These presets validate that the Seeder's density distribution algorithms produce correct relationship patterns. Run them, query the DB, and compare against the expected results below. - -Always use the `--mangle` flag to avoid collisions with existing data. - -## Verification Queries - -Run the first query to get the Organization ID, then paste it into the remaining queries. - -### Find the Organization ID - -```sql -SELECT Id, [Name] -FROM [dbo].[Organization] WITH (NOLOCK) -WHERE [Name] = 'PASTE_ORG_NAME_HERE'; -``` - -### Group Membership Distribution - -```sql -DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE'; - -SELECT - G.[Name], - COUNT(GU.OrganizationUserId) AS Members -FROM [dbo].[Group] G WITH (NOLOCK) -LEFT JOIN [dbo].[GroupUser] GU WITH (NOLOCK) ON G.Id = GU.GroupId -WHERE G.OrganizationId = @OrgId -GROUP BY G.[Name] -ORDER BY Members DESC; -``` - -### CollectionGroup Count - -```sql -DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE'; - -SELECT COUNT(*) AS CollectionGroupCount -FROM [dbo].[CollectionGroup] CG WITH (NOLOCK) -INNER JOIN [dbo].[Collection] C WITH (NOLOCK) ON CG.CollectionId = C.Id -WHERE C.OrganizationId = @OrgId; -``` - -### Permission Distribution - -```sql -DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE'; - -SELECT - 'CollectionUser' AS [Source], - COUNT(*) AS Total, - SUM(CASE WHEN CU.ReadOnly = 1 THEN 1 ELSE 0 END) AS ReadOnly, - SUM(CASE WHEN CU.Manage = 1 THEN 1 ELSE 0 END) AS Manage, - SUM(CASE WHEN CU.HidePasswords = 1 THEN 1 ELSE 0 END) AS HidePasswords, - SUM(CASE WHEN CU.ReadOnly = 0 AND CU.Manage = 0 AND CU.HidePasswords = 0 THEN 1 ELSE 0 END) AS ReadWrite -FROM [dbo].[CollectionUser] CU WITH (NOLOCK) -INNER JOIN [dbo].[OrganizationUser] OU WITH (NOLOCK) ON CU.OrganizationUserId = OU.Id -WHERE OU.OrganizationId = @OrgId -UNION ALL -SELECT - 'CollectionGroup', - COUNT(*), - SUM(CASE WHEN CG.ReadOnly = 1 THEN 1 ELSE 0 END), - SUM(CASE WHEN CG.Manage = 1 THEN 1 ELSE 0 END), - SUM(CASE WHEN CG.HidePasswords = 1 THEN 1 ELSE 0 END), - SUM(CASE WHEN CG.ReadOnly = 0 AND CG.Manage = 0 AND CG.HidePasswords = 0 THEN 1 ELSE 0 END) -FROM [dbo].[CollectionGroup] CG WITH (NOLOCK) -INNER JOIN [dbo].[Collection] C WITH (NOLOCK) ON CG.CollectionId = C.Id -WHERE C.OrganizationId = @OrgId; -``` - -### Orphan Ciphers - -```sql -DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE'; - -SELECT - COUNT(*) AS TotalCiphers, - SUM(CASE WHEN CC.CipherId IS NULL THEN 1 ELSE 0 END) AS Orphans -FROM [dbo].[Cipher] CI WITH (NOLOCK) -LEFT JOIN (SELECT DISTINCT CipherId FROM [dbo].[CollectionCipher] WITH (NOLOCK)) CC - ON CI.Id = CC.CipherId -WHERE CI.OrganizationId = @OrgId; -``` - ---- - -## Presets - -### 1. Power-Law Distribution - -Tests skewed group membership, CollectionGroup generation, permission distribution, and cipher orphans. - -```bash -cd util/SeederUtility -dotnet run -- seed --preset validation.density-modeling-power-law-test --mangle -``` - -| Check | Expected | -| ----------------- | -------------------------------------------------------------------------------------- | -| Groups | 10 groups. First has ~50 members, decays to 1. Last 2 have 0 members (20% empty rate). | -| CollectionGroups | > 0 records. First collections have more groups assigned (PowerLaw fan-out). | -| Permissions | ~50% ReadOnly, ~30% ReadWrite, ~15% Manage, ~5% HidePasswords. | -| Orphan ciphers | ~50 of 500 (10% orphan rate). | -| DirectAccessRatio | 0.6 — roughly 60% of access paths are direct CollectionUser. | - -### 2. MegaGroup Distribution - -Tests one dominant group with all-group access (no direct CollectionUser). - -```bash -cd util/SeederUtility -dotnet run -- seed --preset validation.density-modeling-mega-group-test --mangle -``` - -| Check | Expected | -| ---------------- | ------------------------------------------------------------------------ | -| Groups | 5 groups. Group 1 has ~90 members (90.5%). Groups 2-5 split ~10 members. | -| CollectionUsers | 0 records. DirectAccessRatio is 0.0 — all access via groups. | -| CollectionGroups | > 0. First 10 collections get 3 groups (FrontLoaded), rest get 1. | -| Permissions | 25% each for ReadOnly, ReadWrite, Manage, HidePasswords (even split). | - -### 3. Empty Groups - -Tests that EmptyGroupRate produces memberless groups excluded from CollectionGroup assignment. - -```bash -cd util/SeederUtility -dotnet run -- seed --preset validation.density-modeling-empty-groups-test --mangle -``` - -| Check | Expected | -| ----------------- | ---------------------------------------------------------------------------------- | -| Groups | 10 groups total. 5 with ~10 members each, 5 with 0 members (50% empty). | -| CollectionGroups | Only reference the 5 non-empty groups. Run `SELECT DISTINCT CG.GroupId` to verify. | -| DirectAccessRatio | 0.5 — roughly half of users get direct CollectionUser records. | - -### 4. No Density (Baseline) - -Confirms backward compatibility. No `density` block = original round-robin behavior. - -```bash -cd util/SeederUtility -dotnet run -- seed --preset validation.density-modeling-no-density-test --mangle -``` - -| Check | Expected | -| ---------------- | ---------------------------------------------------------------------------------------- | -| Groups | 5 groups with ~10 members each (uniform round-robin). | -| CollectionGroups | 0 records. No density = no CollectionGroup generation. | -| Permissions | First assignment per user is Manage, subsequent are ReadOnly (original cycling pattern). | -| Orphan ciphers | 0. Every cipher assigned to at least one collection. | diff --git a/util/Seeder/Seeds/fixtures/presets/wonka-teams-small.json b/util/Seeder/Seeds/fixtures/presets/wonka-teams-small.json deleted file mode 100644 index e805409eca20..000000000000 --- a/util/Seeder/Seeds/fixtures/presets/wonka-teams-small.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "../../schemas/preset.schema.json", - "organization": { - "fixture": "wonka-confections", - "planType": "teams-annually", - "seats": 25 - }, - "users": { - "count": 25, - "realisticStatusMix": true - }, - "groups": { - "count": 8 - }, - "collections": { - "count": 15 - }, - "folders": true, - "ciphers": { - "count": 250, - "assignFolders": true - }, - "personalCiphers": { - "countPerUser": 25 - } -} From f5ca67879de84490863c265e6b18b2ec232488f4 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Tue, 10 Mar 2026 01:08:41 -0500 Subject: [PATCH 48/85] PM-31923 fixing architecture to make it clean --- .../OrganizationReportsController.cs | 129 ++----- .../AddOrganizationReportRequestModel.cs | 27 ++ ...zationReportApplicationDataRequestModel.cs | 18 + ...pdateOrganizationReportDataRequestModel.cs | 18 + ...teOrganizationReportSummaryRequestModel.cs | 20 + .../UpdateOrganizationReportV2RequestModel.cs | 30 ++ ...ationReportApplicationDataResponseModel.cs | 13 + .../OrganizationReportDataResponseModel.cs | 13 + ...anizationReportSummaryDataResponseModel.cs | 23 ++ .../OrganizationReportSummaryModel.cs | 9 - .../OrganizationReportSummaryDataResponse.cs | 7 +- .../OrganizationReportsControllerTests.cs | 346 +++--------------- 12 files changed, 252 insertions(+), 401 deletions(-) create mode 100644 src/Api/Dirt/Models/Request/AddOrganizationReportRequestModel.cs create mode 100644 src/Api/Dirt/Models/Request/UpdateOrganizationReportApplicationDataRequestModel.cs create mode 100644 src/Api/Dirt/Models/Request/UpdateOrganizationReportDataRequestModel.cs create mode 100644 src/Api/Dirt/Models/Request/UpdateOrganizationReportSummaryRequestModel.cs create mode 100644 src/Api/Dirt/Models/Request/UpdateOrganizationReportV2RequestModel.cs create mode 100644 src/Api/Dirt/Models/Response/OrganizationReportApplicationDataResponseModel.cs create mode 100644 src/Api/Dirt/Models/Response/OrganizationReportDataResponseModel.cs create mode 100644 src/Api/Dirt/Models/Response/OrganizationReportSummaryDataResponseModel.cs delete mode 100644 src/Api/Dirt/Models/Response/OrganizationReportSummaryModel.cs diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index 83b97a3b46a4..fd8d2f3de6e2 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Bit.Api.Dirt.Models.Request; using Bit.Api.Dirt.Models.Response; using Bit.Api.Utilities; using Bit.Core; @@ -126,7 +127,7 @@ public async Task GetLatestOrganizationReportAsync(Guid organizat [RequestSizeLimit(Constants.FileSize501mb)] public async Task CreateOrganizationReportAsync( Guid organizationId, - [FromBody] AddOrganizationReportRequest request) + [FromBody] AddOrganizationReportRequestModel request) { if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { @@ -135,11 +136,6 @@ public async Task CreateOrganizationReportAsync( throw new BadRequestException("Organization ID is required."); } - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - if (!request.FileSize.HasValue) { throw new BadRequestException("File size is required."); @@ -152,7 +148,7 @@ public async Task CreateOrganizationReportAsync( await AuthorizeAsync(organizationId); - var report = await _createReportCommand.CreateAsync(request); + var report = await _createReportCommand.CreateAsync(request.ToData(organizationId)); var fileData = report.GetReportFile()!; return Ok(new OrganizationReportFileResponseModel @@ -168,12 +164,7 @@ public async Task CreateOrganizationReportAsync( throw new NotFoundException(); } - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - - var v1Report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request); + var v1Report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request.ToData(organizationId)); var response = v1Report == null ? null : new OrganizationReportResponseModel(v1Report); return Ok(response); } @@ -226,7 +217,7 @@ public async Task GetOrganizationReportAsync(Guid organizationId, throw new BadRequestException("Invalid report ID"); } - return Ok(v1Report); + return Ok(new OrganizationReportResponseModel(v1Report)); } // UPDATE Whole Report @@ -235,15 +226,12 @@ public async Task GetOrganizationReportAsync(Guid organizationId, public async Task UpdateOrganizationReportAsync( Guid organizationId, Guid reportId, - [FromBody] UpdateOrganizationReportV2Request request) + [FromBody] UpdateOrganizationReportV2RequestModel request) { if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { await AuthorizeAsync(organizationId); - request.OrganizationId = organizationId; - request.ReportId = reportId; - if (request.RequiresNewFileUpload) { if (!request.FileSize.HasValue) @@ -257,7 +245,8 @@ public async Task UpdateOrganizationReportAsync( } } - var report = await _updateReportV2Command.UpdateAsync(request); + var coreRequest = request.ToData(organizationId, reportId); + var report = await _updateReportV2Command.UpdateAsync(coreRequest); if (request.RequiresNewFileUpload) { @@ -278,11 +267,6 @@ public async Task UpdateOrganizationReportAsync( throw new NotFoundException(); } - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - var v1Request = new UpdateOrganizationReportRequest { ReportId = reportId, @@ -325,7 +309,7 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsyn var summaryDataList = await _getOrganizationReportSummaryDataByDateRangeQuery .GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); - return Ok(summaryDataList); + return Ok(summaryDataList.Select(s => new OrganizationReportSummaryDataResponseModel(s))); } [RequireFeature(FeatureFlagKeys.AccessIntelligenceVersion2)] @@ -445,83 +429,55 @@ private async Task AuthorizeAsync(Guid organizationId) // Removing post v2 launch [HttpPatch("{organizationId}/data/application/{reportId}")] - public async Task UpdateOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportApplicationDataRequest request) + public async Task UpdateOrganizationReportApplicationDataAsync( + Guid organizationId, + Guid reportId, + [FromBody] UpdateOrganizationReportApplicationDataRequestModel request) { - try + if (!await _currentContext.AccessReports(organizationId)) { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } - - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - - if (request.Id != reportId) - { - throw new BadRequestException("Report ID in the request body must match the route parameter"); - } + throw new NotFoundException(); + } - var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request); - var response = new OrganizationReportResponseModel(updatedReport); + var updatedReport = await _updateOrganizationReportApplicationDataCommand + .UpdateOrganizationReportApplicationDataAsync(request.ToData(organizationId, reportId)); + var response = new OrganizationReportResponseModel(updatedReport); - return Ok(response); - } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) - { - throw; - } + return Ok(response); } [HttpGet("{organizationId}/data/application/{reportId}")] public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) { - try + if (!await _currentContext.AccessReports(organizationId)) { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } - - var applicationData = await _getOrganizationReportApplicationDataQuery.GetOrganizationReportApplicationDataAsync(organizationId, reportId); + throw new NotFoundException(); + } - if (applicationData == null) - { - throw new NotFoundException("Organization report application data not found."); - } + var applicationData = await _getOrganizationReportApplicationDataQuery + .GetOrganizationReportApplicationDataAsync(organizationId, reportId); - return Ok(applicationData); - } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + if (applicationData == null) { - throw; + throw new NotFoundException("Organization report application data not found."); } + + return Ok(new OrganizationReportApplicationDataResponseModel(applicationData)); } [HttpPatch("{organizationId}/data/report/{reportId}")] public async Task UpdateOrganizationReportDataAsync( Guid organizationId, Guid reportId, - [FromBody] UpdateOrganizationReportDataRequest request) + [FromBody] UpdateOrganizationReportDataRequestModel request) { if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); } - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - - if (request.ReportId != reportId) - { - throw new BadRequestException("Report ID in the request body must match the route parameter"); - } - - var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request); + var updatedReport = await _updateOrganizationReportDataCommand + .UpdateOrganizationReportDataAsync(request.ToData(organizationId, reportId)); var response = new OrganizationReportResponseModel(updatedReport); return Ok(response); @@ -542,27 +498,22 @@ public async Task GetOrganizationReportDataAsync(Guid organizatio throw new NotFoundException("Organization report data not found."); } - return Ok(reportData); + return Ok(new OrganizationReportDataResponseModel(reportData)); } [HttpPatch("{organizationId}/data/summary/{reportId}")] - public async Task UpdateOrganizationReportSummaryAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportSummaryRequest request) + public async Task UpdateOrganizationReportSummaryAsync( + Guid organizationId, + Guid reportId, + [FromBody] UpdateOrganizationReportSummaryRequestModel request) { if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); } - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - - if (request.ReportId != reportId) - { - throw new BadRequestException("Report ID in the request body must match the route parameter"); - } - var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request); + var updatedReport = await _updateOrganizationReportSummaryCommand + .UpdateOrganizationReportSummaryAsync(request.ToData(organizationId, reportId)); var response = new OrganizationReportResponseModel(updatedReport); return Ok(response); @@ -584,6 +535,6 @@ public async Task GetOrganizationReportSummaryAsync(Guid organiza throw new NotFoundException("Report not found for the specified organization."); } - return Ok(summaryData); + return Ok(new OrganizationReportSummaryDataResponseModel(summaryData)); } } diff --git a/src/Api/Dirt/Models/Request/AddOrganizationReportRequestModel.cs b/src/Api/Dirt/Models/Request/AddOrganizationReportRequestModel.cs new file mode 100644 index 000000000000..24bdcade015d --- /dev/null +++ b/src/Api/Dirt/Models/Request/AddOrganizationReportRequestModel.cs @@ -0,0 +1,27 @@ +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Api.Dirt.Models.Request; + +public class AddOrganizationReportRequestModel +{ + public string? ReportData { get; set; } + public string? ContentEncryptionKey { get; set; } + public string? SummaryData { get; set; } + public string? ApplicationData { get; set; } + public OrganizationReportMetrics? ReportMetrics { get; set; } + public long? FileSize { get; set; } + + public AddOrganizationReportRequest ToData(Guid organizationId) + { + return new AddOrganizationReportRequest + { + OrganizationId = organizationId, + ReportData = ReportData, + ContentEncryptionKey = ContentEncryptionKey, + SummaryData = SummaryData, + ApplicationData = ApplicationData, + ReportMetrics = ReportMetrics, + FileSize = FileSize + }; + } +} diff --git a/src/Api/Dirt/Models/Request/UpdateOrganizationReportApplicationDataRequestModel.cs b/src/Api/Dirt/Models/Request/UpdateOrganizationReportApplicationDataRequestModel.cs new file mode 100644 index 000000000000..9461dc68d9ff --- /dev/null +++ b/src/Api/Dirt/Models/Request/UpdateOrganizationReportApplicationDataRequestModel.cs @@ -0,0 +1,18 @@ +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Api.Dirt.Models.Request; + +public class UpdateOrganizationReportApplicationDataRequestModel +{ + public string? ApplicationData { get; set; } + + public UpdateOrganizationReportApplicationDataRequest ToData(Guid organizationId, Guid reportId) + { + return new UpdateOrganizationReportApplicationDataRequest + { + OrganizationId = organizationId, + Id = reportId, + ApplicationData = ApplicationData + }; + } +} diff --git a/src/Api/Dirt/Models/Request/UpdateOrganizationReportDataRequestModel.cs b/src/Api/Dirt/Models/Request/UpdateOrganizationReportDataRequestModel.cs new file mode 100644 index 000000000000..2a9e4b9faaae --- /dev/null +++ b/src/Api/Dirt/Models/Request/UpdateOrganizationReportDataRequestModel.cs @@ -0,0 +1,18 @@ +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Api.Dirt.Models.Request; + +public class UpdateOrganizationReportDataRequestModel +{ + public string? ReportData { get; set; } + + public UpdateOrganizationReportDataRequest ToData(Guid organizationId, Guid reportId) + { + return new UpdateOrganizationReportDataRequest + { + OrganizationId = organizationId, + ReportId = reportId, + ReportData = ReportData + }; + } +} diff --git a/src/Api/Dirt/Models/Request/UpdateOrganizationReportSummaryRequestModel.cs b/src/Api/Dirt/Models/Request/UpdateOrganizationReportSummaryRequestModel.cs new file mode 100644 index 000000000000..136213a2a3cf --- /dev/null +++ b/src/Api/Dirt/Models/Request/UpdateOrganizationReportSummaryRequestModel.cs @@ -0,0 +1,20 @@ +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Api.Dirt.Models.Request; + +public class UpdateOrganizationReportSummaryRequestModel +{ + public string? SummaryData { get; set; } + public OrganizationReportMetrics? ReportMetrics { get; set; } + + public UpdateOrganizationReportSummaryRequest ToData(Guid organizationId, Guid reportId) + { + return new UpdateOrganizationReportSummaryRequest + { + OrganizationId = organizationId, + ReportId = reportId, + SummaryData = SummaryData, + ReportMetrics = ReportMetrics + }; + } +} diff --git a/src/Api/Dirt/Models/Request/UpdateOrganizationReportV2RequestModel.cs b/src/Api/Dirt/Models/Request/UpdateOrganizationReportV2RequestModel.cs new file mode 100644 index 000000000000..ce4827f8f5e4 --- /dev/null +++ b/src/Api/Dirt/Models/Request/UpdateOrganizationReportV2RequestModel.cs @@ -0,0 +1,30 @@ +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Api.Dirt.Models.Request; + +public class UpdateOrganizationReportV2RequestModel +{ + public string? ReportData { get; set; } + public string? ContentEncryptionKey { get; set; } + public string? SummaryData { get; set; } + public string? ApplicationData { get; set; } + public OrganizationReportMetrics? ReportMetrics { get; set; } + public bool RequiresNewFileUpload { get; set; } + public long? FileSize { get; set; } + + public UpdateOrganizationReportV2Request ToData(Guid organizationId, Guid reportId) + { + return new UpdateOrganizationReportV2Request + { + OrganizationId = organizationId, + ReportId = reportId, + ReportData = ReportData, + ContentEncryptionKey = ContentEncryptionKey, + SummaryData = SummaryData, + ApplicationData = ApplicationData, + ReportMetrics = ReportMetrics, + RequiresNewFileUpload = RequiresNewFileUpload, + FileSize = FileSize + }; + } +} diff --git a/src/Api/Dirt/Models/Response/OrganizationReportApplicationDataResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportApplicationDataResponseModel.cs new file mode 100644 index 000000000000..dd43cfba994f --- /dev/null +++ b/src/Api/Dirt/Models/Response/OrganizationReportApplicationDataResponseModel.cs @@ -0,0 +1,13 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Api.Dirt.Models.Response; + +public class OrganizationReportApplicationDataResponseModel +{ + public OrganizationReportApplicationDataResponseModel(OrganizationReportApplicationDataResponse applicationDataResponse) + { + ApplicationData = applicationDataResponse.ApplicationData; + } + + public string? ApplicationData { get; set; } +} diff --git a/src/Api/Dirt/Models/Response/OrganizationReportDataResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportDataResponseModel.cs new file mode 100644 index 000000000000..1ce5d5fcd8fe --- /dev/null +++ b/src/Api/Dirt/Models/Response/OrganizationReportDataResponseModel.cs @@ -0,0 +1,13 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Api.Dirt.Models.Response; + +public class OrganizationReportDataResponseModel +{ + public OrganizationReportDataResponseModel(OrganizationReportDataResponse reportDataResponse) + { + ReportData = reportDataResponse.ReportData; + } + + public string? ReportData { get; set; } +} diff --git a/src/Api/Dirt/Models/Response/OrganizationReportSummaryDataResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportSummaryDataResponseModel.cs new file mode 100644 index 000000000000..f9fdd127b76e --- /dev/null +++ b/src/Api/Dirt/Models/Response/OrganizationReportSummaryDataResponseModel.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Api.Dirt.Models.Response; + +public class OrganizationReportSummaryDataResponseModel +{ + public OrganizationReportSummaryDataResponseModel(OrganizationReportSummaryDataResponse summaryDataResponse) + { + EncryptedData = summaryDataResponse.SummaryData; + EncryptionKey = summaryDataResponse.ContentEncryptionKey; + Date = summaryDataResponse.RevisionDate; + } + + [JsonPropertyName("encryptedData")] + public string EncryptedData { get; set; } + + [JsonPropertyName("encryptionKey")] + public string EncryptionKey { get; set; } + + [JsonPropertyName("date")] + public DateTime Date { get; set; } +} diff --git a/src/Api/Dirt/Models/Response/OrganizationReportSummaryModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportSummaryModel.cs deleted file mode 100644 index d912fb699e88..000000000000 --- a/src/Api/Dirt/Models/Response/OrganizationReportSummaryModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Bit.Api.Dirt.Models.Response; - -public class OrganizationReportSummaryModel -{ - public Guid OrganizationId { get; set; } - public required string EncryptedData { get; set; } - public required string EncryptionKey { get; set; } - public DateTime Date { get; set; } -} diff --git a/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs index 5c4765db4656..962d4d2e1541 100644 --- a/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs +++ b/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs @@ -1,14 +1,9 @@ -using System.Text.Json.Serialization; - -namespace Bit.Core.Dirt.Models.Data; +namespace Bit.Core.Dirt.Models.Data; public class OrganizationReportSummaryDataResponse { public required Guid OrganizationId { get; set; } - [JsonPropertyName("encryptedData")] public required string SummaryData { get; set; } - [JsonPropertyName("encryptionKey")] public required string ContentEncryptionKey { get; set; } - [JsonPropertyName("date")] public required DateTime RevisionDate { get; set; } } diff --git a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs index f179524f782c..0897880b135d 100644 --- a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs +++ b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs @@ -1,4 +1,5 @@ using Bit.Api.Dirt.Controllers; +using Bit.Api.Dirt.Models.Request; using Bit.Api.Dirt.Models.Response; using Bit.Core; using Bit.Core.Context; @@ -236,11 +237,10 @@ await sutProvider.GetDependency() public async Task CreateOrganizationReportAsync_V1_WithValidRequest_ReturnsOkResult( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request, + AddOrganizationReportRequestModel request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; expectedReport.ReportFile = null; sutProvider.GetDependency() @@ -252,7 +252,7 @@ public async Task CreateOrganizationReportAsync_V1_WithValidRequest_ReturnsOkRes .Returns(true); sutProvider.GetDependency() - .AddOrganizationReportAsync(request) + .AddOrganizationReportAsync(Arg.Any()) .Returns(expectedReport); // Act @@ -268,7 +268,7 @@ public async Task CreateOrganizationReportAsync_V1_WithValidRequest_ReturnsOkRes public async Task CreateOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request) + AddOrganizationReportRequestModel request) { // Arrange sutProvider.GetDependency() @@ -288,46 +288,17 @@ await sutProvider.GetDependency() .AddOrganizationReportAsync(Arg.Any()); } - [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_V1_WithMismatchedOrgId_ThrowsBadRequestException( - SutProvider sutProvider, - Guid orgId, - AddOrganizationReportRequest request) - { - // Arrange - request.OrganizationId = Guid.NewGuid(); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) - .Returns(false); - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); - - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); - - await sutProvider.GetDependency() - .DidNotReceive() - .AddOrganizationReportAsync(Arg.Any()); - } - // CreateOrganizationReportAsync - V2 (flag on) [Theory, BitAutoData] public async Task CreateOrganizationReportAsync_V2_WithValidRequest_ReturnsFileResponseModel( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request, + AddOrganizationReportRequestModel request, OrganizationReport expectedReport, string uploadUrl) { // Arrange - request.OrganizationId = orgId; request.FileSize = 1024; var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false }; @@ -336,7 +307,7 @@ public async Task CreateOrganizationReportAsync_V2_WithValidRequest_ReturnsFileR SetupV2Authorization(sutProvider, orgId); sutProvider.GetDependency() - .CreateAsync(request) + .CreateAsync(Arg.Any()) .Returns(expectedReport); sutProvider.GetDependency() @@ -361,11 +332,10 @@ public async Task CreateOrganizationReportAsync_V2_WithValidRequest_ReturnsFileR [Theory, BitAutoData] public async Task CreateOrganizationReportAsync_V2_EmptyOrgId_ThrowsBadRequestException( SutProvider sutProvider, - AddOrganizationReportRequest request) + AddOrganizationReportRequestModel request) { // Arrange var emptyOrgId = Guid.Empty; - request.OrganizationId = emptyOrgId; sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) @@ -378,34 +348,13 @@ public async Task CreateOrganizationReportAsync_V2_EmptyOrgId_ThrowsBadRequestEx Assert.Equal("Organization ID is required.", exception.Message); } - [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_V2_MismatchedOrgId_ThrowsBadRequestException( - SutProvider sutProvider, - Guid orgId, - AddOrganizationReportRequest request) - { - // Arrange - request.OrganizationId = Guid.NewGuid(); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) - .Returns(true); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); - - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); - } - [Theory, BitAutoData] public async Task CreateOrganizationReportAsync_V2_MissingFileSize_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request) + AddOrganizationReportRequestModel request) { // Arrange - request.OrganizationId = orgId; request.FileSize = null; sutProvider.GetDependency() @@ -423,10 +372,9 @@ public async Task CreateOrganizationReportAsync_V2_MissingFileSize_ThrowsBadRequ public async Task CreateOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request) + AddOrganizationReportRequestModel request) { // Arrange - request.OrganizationId = orgId; request.FileSize = 1024; sutProvider.GetDependency() @@ -457,6 +405,7 @@ public async Task GetOrganizationReportAsync_V1_WithValidIds_ReturnsOkResult( { // Arrange expectedReport.OrganizationId = orgId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) @@ -475,7 +424,9 @@ public async Task GetOrganizationReportAsync_V1_WithValidIds_ReturnsOkResult( // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedReport, okResult.Value); + var response = Assert.IsType(okResult.Value); + Assert.Equal(expectedReport.Id, response.Id); + Assert.Equal(expectedReport.OrganizationId, response.OrganizationId); } [Theory, BitAutoData] @@ -671,11 +622,10 @@ public async Task UpdateOrganizationReportAsync_V1_WithValidRequest_ReturnsOkRes SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportV2Request request, + UpdateOrganizationReportV2RequestModel request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; expectedReport.ReportFile = null; sutProvider.GetDependency() @@ -709,7 +659,7 @@ public async Task UpdateOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundE SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportV2Request request) + UpdateOrganizationReportV2RequestModel request) { // Arrange sutProvider.GetDependency() @@ -729,35 +679,6 @@ await sutProvider.GetDependency() .UpdateOrganizationReportAsync(Arg.Any()); } - [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_V1_WithMismatchedOrgId_ThrowsBadRequestException( - SutProvider sutProvider, - Guid orgId, - Guid reportId, - UpdateOrganizationReportV2Request request) - { - // Arrange - request.OrganizationId = Guid.NewGuid(); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) - .Returns(false); - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request)); - - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); - - await sutProvider.GetDependency() - .DidNotReceive() - .UpdateOrganizationReportAsync(Arg.Any()); - } - // UpdateOrganizationReportAsync - V2 (flag on) [Theory, BitAutoData] @@ -765,7 +686,7 @@ public async Task UpdateOrganizationReportAsync_V2_NoNewFileUpload_ReturnsReport SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportV2Request request, + UpdateOrganizationReportV2RequestModel request, OrganizationReport expectedReport) { // Arrange @@ -775,7 +696,7 @@ public async Task UpdateOrganizationReportAsync_V2_NoNewFileUpload_ReturnsReport SetupV2Authorization(sutProvider, orgId); sutProvider.GetDependency() - .UpdateAsync(request) + .UpdateAsync(Arg.Any()) .Returns(expectedReport); // Act @@ -787,7 +708,7 @@ public async Task UpdateOrganizationReportAsync_V2_NoNewFileUpload_ReturnsReport await sutProvider.GetDependency() .Received(1) - .UpdateAsync(request); + .UpdateAsync(Arg.Any()); } [Theory, BitAutoData] @@ -795,7 +716,7 @@ public async Task UpdateOrganizationReportAsync_V2_WithNewFileUpload_ReturnsFile SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportV2Request request, + UpdateOrganizationReportV2RequestModel request, OrganizationReport expectedReport, string uploadUrl) { @@ -808,7 +729,7 @@ public async Task UpdateOrganizationReportAsync_V2_WithNewFileUpload_ReturnsFile SetupV2Authorization(sutProvider, orgId); sutProvider.GetDependency() - .UpdateAsync(request) + .UpdateAsync(Arg.Any()) .Returns(expectedReport); sutProvider.GetDependency() @@ -835,7 +756,7 @@ public async Task UpdateOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundE SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportV2Request request) + UpdateOrganizationReportV2RequestModel request) { // Arrange sutProvider.GetDependency() @@ -879,7 +800,8 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithValidPara // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedSummaryData, okResult.Value); + var responseList = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal(expectedSummaryData.Count, responseList.Count()); } [Theory, BitAutoData] @@ -942,7 +864,6 @@ public async Task GetOrganizationReportSummaryAsync_WithValidIds_ReturnsOkResult OrganizationReportSummaryDataResponse expectedSummaryData) { // Arrange - sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -956,7 +877,10 @@ public async Task GetOrganizationReportSummaryAsync_WithValidIds_ReturnsOkResult // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedSummaryData, okResult.Value); + var response = Assert.IsType(okResult.Value); + Assert.Equal(expectedSummaryData.SummaryData, response.EncryptedData); + Assert.Equal(expectedSummaryData.ContentEncryptionKey, response.EncryptionKey); + Assert.Equal(expectedSummaryData.RevisionDate, response.Date); } [Theory, BitAutoData] @@ -985,12 +909,10 @@ public async Task UpdateOrganizationReportSummaryAsync_WithValidRequest_ReturnsO SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportSummaryRequest request, + UpdateOrganizationReportSummaryRequestModel request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; - request.ReportId = reportId; expectedReport.ReportFile = null; sutProvider.GetDependency() @@ -998,7 +920,7 @@ public async Task UpdateOrganizationReportSummaryAsync_WithValidRequest_ReturnsO .Returns(true); sutProvider.GetDependency() - .UpdateOrganizationReportSummaryAsync(request) + .UpdateOrganizationReportSummaryAsync(Arg.Any()) .Returns(expectedReport); // Act @@ -1015,7 +937,7 @@ public async Task UpdateOrganizationReportSummaryAsync_WithoutAccess_ThrowsNotFo SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportSummaryRequest request) + UpdateOrganizationReportSummaryRequestModel request) { // Arrange sutProvider.GetDependency() @@ -1032,71 +954,15 @@ await sutProvider.GetDependency() .UpdateOrganizationReportSummaryAsync(Arg.Any()); } - [Theory, BitAutoData] - public async Task UpdateOrganizationReportSummaryAsync_WithMismatchedOrgId_ThrowsBadRequestException( - SutProvider sutProvider, - Guid orgId, - Guid reportId, - UpdateOrganizationReportSummaryRequest request) - { - // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId - request.ReportId = reportId; - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request)); - - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); - - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .UpdateOrganizationReportSummaryAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task UpdateOrganizationReportSummaryAsync_WithMismatchedReportId_ThrowsBadRequestException( - SutProvider sutProvider, - Guid orgId, - Guid reportId, - UpdateOrganizationReportSummaryRequest request) - { - // Arrange - request.OrganizationId = orgId; - request.ReportId = Guid.NewGuid(); // Different from reportId - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request)); - - Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); - - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .UpdateOrganizationReportSummaryAsync(Arg.Any()); - } - [Theory, BitAutoData] public async Task UpdateOrganizationReportSummaryAsync_CallsCorrectMethods( SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportSummaryRequest request, + UpdateOrganizationReportSummaryRequestModel request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; - request.ReportId = reportId; expectedReport.ReportFile = null; sutProvider.GetDependency() @@ -1104,7 +970,7 @@ public async Task UpdateOrganizationReportSummaryAsync_CallsCorrectMethods( .Returns(true); sutProvider.GetDependency() - .UpdateOrganizationReportSummaryAsync(request) + .UpdateOrganizationReportSummaryAsync(Arg.Any()) .Returns(expectedReport); // Act @@ -1117,7 +983,7 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .Received(1) - .UpdateOrganizationReportSummaryAsync(request); + .UpdateOrganizationReportSummaryAsync(Arg.Any()); } // ReportData Field Endpoints @@ -1143,7 +1009,8 @@ public async Task GetOrganizationReportDataAsync_WithValidIds_ReturnsOkResult( // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedReportData, okResult.Value); + var response = Assert.IsType(okResult.Value); + Assert.Equal(expectedReportData.ReportData, response.ReportData); } [Theory, BitAutoData] @@ -1201,12 +1068,10 @@ public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ReturnsOkRe SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportDataRequest request, + UpdateOrganizationReportDataRequestModel request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; - request.ReportId = reportId; expectedReport.ReportFile = null; sutProvider.GetDependency() @@ -1214,7 +1079,7 @@ public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ReturnsOkRe .Returns(true); sutProvider.GetDependency() - .UpdateOrganizationReportDataAsync(request) + .UpdateOrganizationReportDataAsync(Arg.Any()) .Returns(expectedReport); // Act @@ -1231,7 +1096,7 @@ public async Task UpdateOrganizationReportDataAsync_WithoutAccess_ThrowsNotFound SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportDataRequest request) + UpdateOrganizationReportDataRequestModel request) { // Arrange sutProvider.GetDependency() @@ -1248,71 +1113,15 @@ await sutProvider.GetDependency() .UpdateOrganizationReportDataAsync(Arg.Any()); } - [Theory, BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithMismatchedOrgId_ThrowsBadRequestException( - SutProvider sutProvider, - Guid orgId, - Guid reportId, - UpdateOrganizationReportDataRequest request) - { - // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId - request.ReportId = reportId; - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); - - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); - - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .UpdateOrganizationReportDataAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task UpdateOrganizationReportDataAsync_WithMismatchedReportId_ThrowsBadRequestException( - SutProvider sutProvider, - Guid orgId, - Guid reportId, - UpdateOrganizationReportDataRequest request) - { - // Arrange - request.OrganizationId = orgId; - request.ReportId = Guid.NewGuid(); // Different from reportId - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); - - Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); - - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .UpdateOrganizationReportDataAsync(Arg.Any()); - } - [Theory, BitAutoData] public async Task UpdateOrganizationReportDataAsync_CallsCorrectMethods( SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportDataRequest request, + UpdateOrganizationReportDataRequestModel request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; - request.ReportId = reportId; expectedReport.ReportFile = null; sutProvider.GetDependency() @@ -1320,7 +1129,7 @@ public async Task UpdateOrganizationReportDataAsync_CallsCorrectMethods( .Returns(true); sutProvider.GetDependency() - .UpdateOrganizationReportDataAsync(request) + .UpdateOrganizationReportDataAsync(Arg.Any()) .Returns(expectedReport); // Act @@ -1333,7 +1142,7 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .Received(1) - .UpdateOrganizationReportDataAsync(request); + .UpdateOrganizationReportDataAsync(Arg.Any()); } // ApplicationData Field Endpoints @@ -1359,7 +1168,8 @@ public async Task GetOrganizationReportApplicationDataAsync_WithValidIds_Returns // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedApplicationData, okResult.Value); + var response = Assert.IsType(okResult.Value); + Assert.Equal(expectedApplicationData.ApplicationData, response.ApplicationData); } [Theory, BitAutoData] @@ -1439,13 +1249,10 @@ public async Task UpdateOrganizationReportApplicationDataAsync_WithValidRequest_ SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportApplicationDataRequest request, + UpdateOrganizationReportApplicationDataRequestModel request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; - request.Id = reportId; - expectedReport.Id = request.Id; expectedReport.ReportFile = null; sutProvider.GetDependency() @@ -1453,7 +1260,7 @@ public async Task UpdateOrganizationReportApplicationDataAsync_WithValidRequest_ .Returns(true); sutProvider.GetDependency() - .UpdateOrganizationReportApplicationDataAsync(request) + .UpdateOrganizationReportApplicationDataAsync(Arg.Any()) .Returns(expectedReport); // Act @@ -1470,7 +1277,7 @@ public async Task UpdateOrganizationReportApplicationDataAsync_WithoutAccess_Thr SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportApplicationDataRequest request) + UpdateOrganizationReportApplicationDataRequestModel request) { // Arrange sutProvider.GetDependency() @@ -1487,70 +1294,15 @@ await sutProvider.GetDependency .UpdateOrganizationReportApplicationDataAsync(Arg.Any()); } - [Theory, BitAutoData] - public async Task UpdateOrganizationReportApplicationDataAsync_WithMismatchedOrgId_ThrowsBadRequestException( - SutProvider sutProvider, - Guid orgId, - Guid reportId, - UpdateOrganizationReportApplicationDataRequest request) - { - // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request)); - - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); - - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .UpdateOrganizationReportApplicationDataAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task UpdateOrganizationReportApplicationDataAsync_WithMismatchedReportId_ThrowsBadRequestException( - SutProvider sutProvider, - Guid orgId, - Guid reportId, - UpdateOrganizationReportApplicationDataRequest request, - OrganizationReport updatedReport) - { - // Arrange - request.OrganizationId = orgId; - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); - - sutProvider.GetDependency() - .UpdateOrganizationReportApplicationDataAsync(request) - .Returns(updatedReport); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request)); - - Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); - } - [Theory, BitAutoData] public async Task UpdateOrganizationReportApplicationDataAsync_CallsCorrectMethods( SutProvider sutProvider, Guid orgId, Guid reportId, - UpdateOrganizationReportApplicationDataRequest request, + UpdateOrganizationReportApplicationDataRequestModel request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; - request.Id = reportId; - expectedReport.Id = reportId; expectedReport.ReportFile = null; sutProvider.GetDependency() @@ -1558,7 +1310,7 @@ public async Task UpdateOrganizationReportApplicationDataAsync_CallsCorrectMetho .Returns(true); sutProvider.GetDependency() - .UpdateOrganizationReportApplicationDataAsync(request) + .UpdateOrganizationReportApplicationDataAsync(Arg.Any()) .Returns(expectedReport); // Act @@ -1571,7 +1323,7 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .Received(1) - .UpdateOrganizationReportApplicationDataAsync(request); + .UpdateOrganizationReportApplicationDataAsync(Arg.Any()); } // Helper method for setting up V2 authorization mocks From 93f2dab202c1a4bca2b07730cd309e3ad25f0189 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Tue, 10 Mar 2026 01:45:43 -0500 Subject: [PATCH 49/85] PM-31923 adding XML docs to controllers --- .../OrganizationReportsController.cs | 68 ++++++++++++++++--- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index fd8d2f3de6e2..e3618405d214 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -83,6 +83,13 @@ public OrganizationReportsController( } + /// + /// Gets the most recent organization report for the specified organization. + /// When the Access Intelligence V2 feature flag is enabled, includes a presigned download URL + /// for the report file if one has been validated. Otherwise, returns the report metadata only. + /// + /// The unique identifier of the organization. + /// An , or null if no reports exist. [HttpGet("{organizationId}/latest")] public async Task GetLatestOrganizationReportAsync(Guid organizationId) { @@ -118,11 +125,16 @@ public async Task GetLatestOrganizationReportAsync(Guid organizat return Ok(v1Response); } - /** - * Keeping post v2 launch of Access Intelligence - **/ - - // CREATE Whole Report + /// + /// Creates a new organization report for the specified organization. + /// When the Access Intelligence V2 feature flag is enabled, validates the file size and returns + /// a presigned upload URL for the report file along with the created report metadata. + /// Otherwise, creates the report with inline data. + /// + /// The unique identifier of the organization. + /// The request model containing report data and optional file metadata. + /// An with upload URL when V2 is enabled, + /// or an otherwise. [HttpPost("{organizationId}")] [RequestSizeLimit(Constants.FileSize501mb)] public async Task CreateOrganizationReportAsync( @@ -169,7 +181,15 @@ public async Task CreateOrganizationReportAsync( return Ok(response); } - // READ Whole Report BY IDs + /// + /// Gets a specific organization report by its report ID. + /// Validates that the report belongs to the specified organization. + /// When the Access Intelligence V2 feature flag is enabled, includes a presigned download URL + /// for the report file if one has been validated. + /// + /// The unique identifier of the organization. + /// The unique identifier of the report to retrieve. + /// An matching the specified IDs. [HttpGet("{organizationId}/{reportId}")] public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) { @@ -220,7 +240,17 @@ public async Task GetOrganizationReportAsync(Guid organizationId, return Ok(new OrganizationReportResponseModel(v1Report)); } - // UPDATE Whole Report + /// + /// Updates an existing organization report for the specified organization. + /// When the Access Intelligence V2 feature flag is enabled and a new file upload is required, + /// validates the file size and returns a presigned upload URL. + /// Otherwise, updates the report metadata and inline data. + /// + /// The unique identifier of the organization. + /// The unique identifier of the report to update. + /// The request model containing updated report data and optional file metadata. + /// An with upload URL when a new file is required, + /// or an otherwise. [HttpPatch("{organizationId}/{reportId}")] [RequestSizeLimit(Constants.FileSize501mb)] public async Task UpdateOrganizationReportAsync( @@ -288,10 +318,10 @@ public async Task UpdateOrganizationReportAsync( /// evenly spaced across the date range, including the most recent entry. /// This allows the widget to show trends over time while ensuring the latest data point is always included. /// - /// - /// - /// - /// + /// The unique identifier of the organization. + /// The start of the date range to query. + /// The end of the date range to query. + /// A collection of entries spaced across the date range. [HttpGet("{organizationId}/data/summary")] public async Task GetOrganizationReportSummaryDataByDateRangeAsync( Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) @@ -312,6 +342,14 @@ public async Task GetOrganizationReportSummaryDataByDateRangeAsyn return Ok(summaryDataList.Select(s => new OrganizationReportSummaryDataResponseModel(s))); } + /// + /// Uploads a report data file for a self-hosted organization report via multipart form data. + /// Validates the uploaded file size against the expected size (with a 1 MB leeway) and marks + /// the report file as validated upon success. Requires the Access Intelligence V2 feature flag. + /// + /// The unique identifier of the organization. + /// The unique identifier of the report to attach the file to. + /// The identifier of the report file entry to upload against. [RequireFeature(FeatureFlagKeys.AccessIntelligenceVersion2)] [HttpPost("{organizationId}/{reportId}/file/report-data")] [SelfHosted(SelfHostedOnly = true)] @@ -369,6 +407,14 @@ await Request.GetFileAsync(async (stream) => await _organizationReportRepo.ReplaceAsync(report); } + /// + /// Handles Azure Event Grid webhook notifications for blob storage events. + /// When a Microsoft.Storage.BlobCreated event is received, validates the uploaded + /// report file against the corresponding organization report. Orphaned blobs (with no + /// matching report) are deleted. Requires the Access Intelligence V2 feature flag. + /// This endpoint is anonymous to allow Azure Event Grid to call it directly. + /// + /// An acknowledging the Event Grid event. [AllowAnonymous] [RequireFeature(FeatureFlagKeys.AccessIntelligenceVersion2)] [HttpPost("file/validate/azure")] From 3b417132f6a005b8ac51875403ff0cc453fe8ec0 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Tue, 10 Mar 2026 08:33:46 +0000 Subject: [PATCH 50/85] Existing device scene (#7155) * Existing device scene * Prefer usings * Require namespaces * Return the device id that is created --- util/Seeder/Factories/DeviceSeeder.cs | 21 ++++++++++++ util/Seeder/Scenes/UserDeviceScene.cs | 47 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 util/Seeder/Factories/DeviceSeeder.cs create mode 100644 util/Seeder/Scenes/UserDeviceScene.cs diff --git a/util/Seeder/Factories/DeviceSeeder.cs b/util/Seeder/Factories/DeviceSeeder.cs new file mode 100644 index 000000000000..70192fb07e7f --- /dev/null +++ b/util/Seeder/Factories/DeviceSeeder.cs @@ -0,0 +1,21 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Utilities; + +namespace Bit.Seeder.Factories; + +internal static class DeviceSeeder +{ + internal static Device Create(Guid userId, DeviceType deviceType, string deviceName, string identifier, string? pushToken) + { + return new Device + { + Id = CoreHelpers.GenerateComb(), + UserId = userId, + Type = deviceType, + Name = deviceName, + Identifier = identifier, + PushToken = pushToken + }; + } +} diff --git a/util/Seeder/Scenes/UserDeviceScene.cs b/util/Seeder/Scenes/UserDeviceScene.cs new file mode 100644 index 000000000000..9c03103e2e63 --- /dev/null +++ b/util/Seeder/Scenes/UserDeviceScene.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Seeder.Factories; +using Bit.Seeder.Services; + +namespace Bit.Seeder.Scenes; + +public class UserDeviceScene(IUserRepository userRepository, IDeviceRepository deviceRepository, IManglerService manglerService) : IScene +{ + public class Request + { + [Required] + public required Guid UserId { get; set; } + [Required] + public required DeviceType Type { get; set; } + [Required] + public required string Name { get; set; } + [Required] + public required string Identifier { get; set; } + public string? PushToken { get; set; } + } + + public class Result + { + public Guid DeviceId { get; init; } + } + + public async Task> SeedAsync(Request request) + { + var user = await userRepository.GetByIdAsync(request.UserId); + if (user == null) + { + throw new Exception($"User with ID {request.UserId} not found."); + } + + var device = DeviceSeeder.Create(request.UserId, request.Type, request.Name, request.Identifier, request.PushToken); + await deviceRepository.CreateAsync(device); + + return new SceneResult( + result: new Result + { + DeviceId = device.Id, + }, + mangleMap: manglerService.GetMangleMap()); + } +} From ca50a40a49d5d7a70c26123145317d338199b5b3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:55:14 +0100 Subject: [PATCH 51/85] [deps]: Update MarkDig to v1 (#7120) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> --- src/Billing/Billing.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index a7ea9f654eb1..201f8f6e422f 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -20,7 +20,7 @@ - + From 4f88493a06a96fbdc882dc333419ba701733f938 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Tue, 10 Mar 2026 09:34:53 -0400 Subject: [PATCH 52/85] remove feature flag (#7180) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 2578249df1a8..ad129461eaa3 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -142,7 +142,6 @@ public static class FeatureFlagKeys public const string ScimRevokeV2 = "pm-32394-scim-revoke-put-v2"; public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance"; public const string BulkReinviteUI = "pm-28416-bulk-reinvite-ux-improvements"; - public const string UpdateJoinOrganizationEmailTemplate = "pm-28396-update-join-organization-email-template"; public const string RefactorOrgAcceptInit = "pm-33082-refactor-org-accept-init"; /* Architecture */ From c8413e8719e1ed50ca595f7a1a4a7c183c9dbf70 Mon Sep 17 00:00:00 2001 From: Jared Date: Tue, 10 Mar 2026 10:49:35 -0400 Subject: [PATCH 53/85] [PM-32666] Fixes endpoint issue where you can update another by providing a valid org ID (#7185) * fix(controller): add null check for provider organization ID in ProviderClientsController * feat(tests): add test for updating provider organization with different provider ID --- .../Controllers/ProviderClientsController.cs | 5 +++++ .../ProviderClientsControllerTests.cs | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs index dfa698482679..602680b5bf86 100644 --- a/src/Api/AdminConsole/Controllers/ProviderClientsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderClientsController.cs @@ -107,6 +107,11 @@ public async Task UpdateAsync( return Error.NotFound(); } + if (providerOrganization.ProviderId != provider.Id) + { + return Error.NotFound(); + } + var clientOrganization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId); if (clientOrganization is not { Status: OrganizationStatusType.Managed }) diff --git a/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs index 259797dfb32b..5e573f6a6808 100644 --- a/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/ProviderClientsControllerTests.cs @@ -107,6 +107,7 @@ public async Task UpdateAsync_ServiceUserMakingPurchase_Unauthorized( organization.Seats = 10; organization.Status = OrganizationStatusType.Managed; requestBody.AssignedSeats = 20; + providerOrganization.ProviderId = provider.Id; ConfigureStableProviderServiceUserInputs(provider, sutProvider); @@ -128,6 +129,26 @@ public async Task UpdateAsync_ServiceUserMakingPurchase_Unauthorized( AssertUnauthorized(result, message: "Service users cannot purchase additional seats."); } + [Theory, BitAutoData] + public async Task UpdateAsync_ProviderOrganizationBelongsToDifferentProvider_NotFound( + Provider provider, + Guid providerOrganizationId, + UpdateClientOrganizationRequestBody requestBody, + ProviderOrganization providerOrganization, + SutProvider sutProvider) + { + ConfigureStableProviderServiceUserInputs(provider, sutProvider); + + providerOrganization.ProviderId = Guid.NewGuid(); + + sutProvider.GetDependency().GetByIdAsync(providerOrganizationId) + .Returns(providerOrganization); + + var result = await sutProvider.Sut.UpdateAsync(provider.Id, providerOrganizationId, requestBody); + + AssertNotFound(result); + } + [Theory, BitAutoData] public async Task UpdateAsync_Ok( Provider provider, @@ -141,6 +162,7 @@ public async Task UpdateAsync_Ok( organization.Seats = 10; organization.Status = OrganizationStatusType.Managed; requestBody.AssignedSeats = 20; + providerOrganization.ProviderId = provider.Id; ConfigureStableProviderServiceUserInputs(provider, sutProvider); From da9e4ccbfd7dbe69825a47fd78f45ba3cb2d7722 Mon Sep 17 00:00:00 2001 From: Jared Date: Tue, 10 Mar 2026 10:50:58 -0400 Subject: [PATCH 54/85] fix(OrganizationsController): Remove unused GetPlanType method to streamline organization management (#7177) --- .../Controllers/OrganizationsController.cs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index a6de8c521fa1..4aa4bdf475c3 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -572,16 +572,4 @@ public async Task PutCollectionManagement(Guid id, [F return new OrganizationResponseModel(organization, plan); } - [HttpGet("{id}/plan-type")] - public async Task GetPlanType(string id) - { - var orgIdGuid = new Guid(id); - var organization = await _organizationRepository.GetByIdAsync(orgIdGuid); - if (organization == null) - { - throw new NotFoundException(); - } - - return organization.PlanType; - } } From 69fc2706b596146b33760537d8d3d539c486e6fa Mon Sep 17 00:00:00 2001 From: mpbw2 <59324545+mpbw2@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:20:30 -0400 Subject: [PATCH 55/85] added pm-31697-premium-upgrade-path feature flag (#7162) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ad129461eaa3..ca15c6e26170 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -225,6 +225,7 @@ public static class FeatureFlagKeys public const string CxpImportMobile = "cxp-import-mobile"; public const string CxpExportMobile = "cxp-export-mobile"; public const string DeviceAuthKey = "pm-27581-device-auth-key"; + public const string PremiumUpgradePath = "pm-31697-premium-upgrade-path"; /* Platform Team */ public const string WebPush = "web-push"; From 451ca9b93cd70aaf99194c47090209a2749e9df6 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Tue, 10 Mar 2026 16:22:23 +0100 Subject: [PATCH 56/85] Seeder - Adding density distributions (#7191) --- .../CreateCollectionsStepTests.cs | 86 ++++++++++++- .../MultiCollectionAssignmentTests.cs | 109 ++++++++++++++++ .../DensityModel/RangeCalculationTests.cs | 53 ++++++++ .../DistributionTests.cs | 38 ++++++ .../Seeder/Data/Distributions/Distribution.cs | 50 ++++++-- .../Distributions/FolderCountDistributions.cs | 21 +++- .../PersonalCipherDistributions.cs | 37 ++++++ util/Seeder/Models/SeedPresetDensity.cs | 60 +++++++++ util/Seeder/Options/DensityProfile.cs | 48 ++++++- util/Seeder/Pipeline/PresetLoader.cs | 119 ++++++++++++++++-- .../Pipeline/RecipeBuilderExtensions.cs | 19 ++- util/Seeder/Pipeline/RecipeOrchestrator.cs | 2 +- util/Seeder/Seeds/schemas/preset.schema.json | 106 ++++++++++++++++ util/Seeder/Steps/CreateCollectionsStep.cs | 41 +++++- util/Seeder/Steps/GenerateCiphersStep.cs | 26 +++- util/Seeder/Steps/GenerateFoldersStep.cs | 9 +- .../Steps/GeneratePersonalCiphersStep.cs | 45 +++++-- 17 files changed, 817 insertions(+), 52 deletions(-) create mode 100644 test/SeederApi.IntegrationTest/DensityModel/MultiCollectionAssignmentTests.cs create mode 100644 test/SeederApi.IntegrationTest/DensityModel/RangeCalculationTests.cs create mode 100644 util/Seeder/Data/Distributions/PersonalCipherDistributions.cs diff --git a/test/SeederApi.IntegrationTest/DensityModel/CreateCollectionsStepTests.cs b/test/SeederApi.IntegrationTest/DensityModel/CreateCollectionsStepTests.cs index 630d7f56a9bd..95d81dd5f49d 100644 --- a/test/SeederApi.IntegrationTest/DensityModel/CreateCollectionsStepTests.cs +++ b/test/SeederApi.IntegrationTest/DensityModel/CreateCollectionsStepTests.cs @@ -124,7 +124,9 @@ public void BuildCollectionGroups_Uniform_AssignsGroupsToEveryCollection() [Fact] public void BuildCollectionUsers_AllCollectionIdsAreValid() { - var result = CreateCollectionsStep.BuildCollectionUsers(_collectionIds, _userIds, 10); + var step = CreateStep(CollectionFanOutShape.Uniform, min: 1, max: 3); + + var result = step.BuildCollectionUsers(_collectionIds, _userIds, 10); Assert.All(result, cu => Assert.Contains(cu.CollectionId, _collectionIds)); } @@ -132,7 +134,9 @@ public void BuildCollectionUsers_AllCollectionIdsAreValid() [Fact] public void BuildCollectionUsers_AssignsOneToThreeCollectionsPerUser() { - var result = CreateCollectionsStep.BuildCollectionUsers(_collectionIds, _userIds, 10); + var step = CreateStep(CollectionFanOutShape.Uniform, min: 1, max: 3); + + var result = step.BuildCollectionUsers(_collectionIds, _userIds, 10); var perUser = result.GroupBy(cu => cu.OrganizationUserId).ToList(); Assert.All(perUser, group => Assert.InRange(group.Count(), 1, 3)); @@ -141,7 +145,9 @@ public void BuildCollectionUsers_AssignsOneToThreeCollectionsPerUser() [Fact] public void BuildCollectionUsers_RespectsDirectUserCount() { - var result = CreateCollectionsStep.BuildCollectionUsers(_collectionIds, _userIds, 5); + var step = CreateStep(CollectionFanOutShape.Uniform, min: 1, max: 3); + + var result = step.BuildCollectionUsers(_collectionIds, _userIds, 5); var distinctUsers = result.Select(cu => cu.OrganizationUserId).Distinct().ToList(); Assert.Equal(5, distinctUsers.Count); @@ -201,6 +207,67 @@ public void ComputeFanOut_Uniform_CyclesThroughRange() Assert.Equal(1, step.ComputeFanOut(3, 10, 1, 3)); } + [Fact] + public void ComputeCollectionsPerUser_Uniform_CyclesThroughRange() + { + var step = CreateUserCollectionStep(CollectionFanOutShape.Uniform, min: 1, max: 5); + + Assert.Equal(1, step.ComputeCollectionsPerUser(0, 100, 1, 5)); + Assert.Equal(2, step.ComputeCollectionsPerUser(1, 100, 1, 5)); + Assert.Equal(5, step.ComputeCollectionsPerUser(4, 100, 1, 5)); + Assert.Equal(1, step.ComputeCollectionsPerUser(5, 100, 1, 5)); + } + + [Fact] + public void ComputeCollectionsPerUser_PowerLaw_FirstUserGetsMax() + { + var step = CreateUserCollectionStep(CollectionFanOutShape.PowerLaw, min: 1, max: 50, skew: 0.7); + + Assert.Equal(50, step.ComputeCollectionsPerUser(0, 1000, 1, 50)); + } + + [Fact] + public void ComputeCollectionsPerUser_PowerLaw_LastUsersGetMin() + { + var step = CreateUserCollectionStep(CollectionFanOutShape.PowerLaw, min: 1, max: 50, skew: 0.7); + + Assert.Equal(1, step.ComputeCollectionsPerUser(999, 1000, 1, 50)); + } + + [Fact] + public void ComputeCollectionsPerUser_PowerLaw_DecaysMonotonically() + { + var step = CreateUserCollectionStep(CollectionFanOutShape.PowerLaw, min: 1, max: 25, skew: 0.6); + + var prev = step.ComputeCollectionsPerUser(0, 500, 1, 25); + for (var i = 1; i < 500; i++) + { + var current = step.ComputeCollectionsPerUser(i, 500, 1, 25); + Assert.True(current <= prev, $"Index {i}: {current} > {prev}"); + prev = current; + } + } + + [Fact] + public void ComputeCollectionsPerUser_FrontLoaded_FirstTenPercentGetMax() + { + var step = CreateUserCollectionStep(CollectionFanOutShape.FrontLoaded, min: 1, max: 20); + + Assert.Equal(20, step.ComputeCollectionsPerUser(0, 100, 1, 20)); + Assert.Equal(20, step.ComputeCollectionsPerUser(9, 100, 1, 20)); + Assert.Equal(1, step.ComputeCollectionsPerUser(10, 100, 1, 20)); + Assert.Equal(1, step.ComputeCollectionsPerUser(99, 100, 1, 20)); + } + + [Fact] + public void ComputeCollectionsPerUser_RangeOfOne_ReturnsMin() + { + var step = CreateUserCollectionStep(CollectionFanOutShape.PowerLaw, min: 3, max: 3, skew: 0.8); + + Assert.Equal(3, step.ComputeCollectionsPerUser(0, 100, 3, 3)); + Assert.Equal(3, step.ComputeCollectionsPerUser(99, 100, 3, 3)); + } + private static CreateCollectionsStep CreateStep(CollectionFanOutShape shape, int min, int max) { var density = new DensityProfile @@ -211,4 +278,17 @@ private static CreateCollectionsStep CreateStep(CollectionFanOutShape shape, int }; return CreateCollectionsStep.FromCount(0, density); } + + private static CreateCollectionsStep CreateUserCollectionStep( + CollectionFanOutShape shape, int min, int max, double skew = 0) + { + var density = new DensityProfile + { + UserCollectionShape = shape, + UserCollectionMin = min, + UserCollectionMax = max, + UserCollectionSkew = skew + }; + return CreateCollectionsStep.FromCount(0, density); + } } diff --git a/test/SeederApi.IntegrationTest/DensityModel/MultiCollectionAssignmentTests.cs b/test/SeederApi.IntegrationTest/DensityModel/MultiCollectionAssignmentTests.cs new file mode 100644 index 000000000000..423cb3c5ddf7 --- /dev/null +++ b/test/SeederApi.IntegrationTest/DensityModel/MultiCollectionAssignmentTests.cs @@ -0,0 +1,109 @@ +using Xunit; + +namespace Bit.SeederApi.IntegrationTest.DensityModel; + +/// +/// Validates the multi-collection cipher assignment math from GenerateCiphersStep +/// to ensure no duplicate (CipherId, CollectionId) pairs are produced. +/// +public class MultiCollectionAssignmentTests +{ + /// + /// Simulates the secondary collection assignment loop from GenerateCiphersStep + /// with the extraCount clamp fix applied. Returns the list of (cipherIndex, collectionIndex) pairs. + /// + private static List<(int CipherIndex, int CollectionIndex)> SimulateMultiCollectionAssignment( + int cipherCount, + int collectionCount, + double multiCollectionRate, + int maxCollectionsPerCipher) + { + var primaryIndices = new int[cipherCount]; + var pairs = new List<(int, int)>(); + + for (var i = 0; i < cipherCount; i++) + { + primaryIndices[i] = i % collectionCount; + pairs.Add((i, primaryIndices[i])); + } + + if (multiCollectionRate > 0 && collectionCount > 1) + { + var multiCount = (int)(cipherCount * multiCollectionRate); + for (var i = 0; i < multiCount; i++) + { + var extraCount = 1 + (i % Math.Max(maxCollectionsPerCipher - 1, 1)); + extraCount = Math.Min(extraCount, collectionCount - 1); + for (var j = 0; j < extraCount; j++) + { + var secondaryIndex = (primaryIndices[i] + 1 + j) % collectionCount; + pairs.Add((i, secondaryIndex)); + } + } + } + + return pairs; + } + + [Fact] + public void MultiCollectionAssignment_SmallCollectionCount_NoDuplicates() + { + var pairs = SimulateMultiCollectionAssignment( + cipherCount: 20, + collectionCount: 3, + multiCollectionRate: 1.0, + maxCollectionsPerCipher: 5); + + var grouped = pairs.GroupBy(p => p); + Assert.All(grouped, g => Assert.Single(g)); + } + + [Fact] + public void MultiCollectionAssignment_TwoCollections_NoDuplicates() + { + var pairs = SimulateMultiCollectionAssignment( + cipherCount: 50, + collectionCount: 2, + multiCollectionRate: 1.0, + maxCollectionsPerCipher: 10); + + var grouped = pairs.GroupBy(p => p); + Assert.All(grouped, g => Assert.Single(g)); + } + + [Fact] + public void MultiCollectionAssignment_ExtraCountClamped_ToAvailableCollections() + { + // With 2 collections, extraCount should never exceed 1 (collectionCount - 1) + var collectionCount = 2; + var maxCollectionsPerCipher = 10; + var cipherCount = 20; + + for (var i = 0; i < cipherCount; i++) + { + var extraCount = 1 + (i % Math.Max(maxCollectionsPerCipher - 1, 1)); + extraCount = Math.Min(extraCount, collectionCount - 1); + Assert.True(extraCount <= collectionCount - 1, + $"extraCount {extraCount} exceeds available secondary slots {collectionCount - 1} at i={i}"); + } + } + + [Fact] + public void MultiCollectionAssignment_SecondaryNeverEqualsPrimary() + { + var pairs = SimulateMultiCollectionAssignment( + cipherCount: 30, + collectionCount: 3, + multiCollectionRate: 1.0, + maxCollectionsPerCipher: 5); + + // Group by cipher index — for each cipher, no secondary should equal primary + var byCipher = pairs.GroupBy(p => p.CipherIndex); + foreach (var group in byCipher) + { + var primary = group.First().CollectionIndex; + var secondaries = group.Skip(1).Select(p => p.CollectionIndex); + Assert.DoesNotContain(primary, secondaries); + } + } +} diff --git a/test/SeederApi.IntegrationTest/DensityModel/RangeCalculationTests.cs b/test/SeederApi.IntegrationTest/DensityModel/RangeCalculationTests.cs new file mode 100644 index 000000000000..7626545dfc60 --- /dev/null +++ b/test/SeederApi.IntegrationTest/DensityModel/RangeCalculationTests.cs @@ -0,0 +1,53 @@ +using Xunit; + +namespace Bit.SeederApi.IntegrationTest.DensityModel; + +/// +/// Validates the range calculation formula used in GeneratePersonalCiphersStep and GenerateFoldersStep. +/// The formula: range.Min + (index % Math.Max(range.Max - range.Min + 1, 1)) +/// +public class RangeCalculationTests +{ + private static int ComputeFromRange(int min, int max, int index) + { + return min + (index % Math.Max(max - min + 1, 1)); + } + + [Fact] + public void RangeFormula_SmallRange_ProducesBothMinAndMax() + { + var values = Enumerable.Range(0, 100).Select(i => ComputeFromRange(0, 1, i)).ToHashSet(); + + Assert.Contains(0, values); + Assert.Contains(1, values); + } + + [Fact] + public void RangeFormula_LargerRange_MaxIsReachable() + { + var values = Enumerable.Range(0, 1000).Select(i => ComputeFromRange(5, 15, i)).ToHashSet(); + + Assert.Contains(5, values); + Assert.Contains(15, values); + Assert.Equal(11, values.Count); // 5,6,7,...,15 + } + + [Fact] + public void RangeFormula_SingleValue_AlwaysReturnsMin() + { + var values = Enumerable.Range(0, 50).Select(i => ComputeFromRange(3, 3, i)).Distinct().ToList(); + + Assert.Single(values); + Assert.Equal(3, values[0]); + } + + [Fact] + public void RangeFormula_AllValuesInBounds() + { + for (var i = 0; i < 500; i++) + { + var result = ComputeFromRange(50, 200, i); + Assert.InRange(result, 50, 200); + } + } +} diff --git a/test/SeederApi.IntegrationTest/DistributionTests.cs b/test/SeederApi.IntegrationTest/DistributionTests.cs index 808e31c273cd..de4c48fff222 100644 --- a/test/SeederApi.IntegrationTest/DistributionTests.cs +++ b/test/SeederApi.IntegrationTest/DistributionTests.cs @@ -172,4 +172,42 @@ public void Select_IsDeterministic_SameInputSameOutput() Assert.Equal(first, second); } } + + [Fact] + public void Select_ZeroWeightBucket_NeverSelected() + { + var distribution = new Distribution( + ("Manage", 0.50), + ("ReadWrite", 0.40), + ("ReadOnly", 0.10), + ("HidePasswords", 0.0) + ); + + for (var i = 0; i < 7; i++) + { + Assert.NotEqual("HidePasswords", distribution.Select(i, 7)); + } + } + + [Fact] + public void GetCounts_SmallTotal_RemainderGoesToLargestFraction() + { + var distribution = new Distribution( + ("A", 0.50), + ("B", 0.40), + ("C", 0.10), + ("D", 0.0) + ); + + var counts = distribution.GetCounts(7).ToList(); + + // Exact: A=3.5, B=2.8, C=0.7, D=0.0 + // Floors: A=3, B=2, C=0, D=0 (sum=5, deficit=2) + // Remainders: A=0.5, B=0.8, C=0.7, D=0.0 + // Deficit 1 → B (0.8), Deficit 2 → C (0.7) + Assert.Equal(("A", 3), counts[0]); + Assert.Equal(("B", 3), counts[1]); + Assert.Equal(("C", 1), counts[2]); + Assert.Equal(("D", 0), counts[3]); + } } diff --git a/util/Seeder/Data/Distributions/Distribution.cs b/util/Seeder/Data/Distributions/Distribution.cs index 8a44a46e320e..27b96cc70b5a 100644 --- a/util/Seeder/Data/Distributions/Distribution.cs +++ b/util/Seeder/Data/Distributions/Distribution.cs @@ -26,40 +26,68 @@ public Distribution(params (T Value, double Percentage)[] buckets) /// /// Selects a value deterministically based on index position within a total count. - /// Items 0 to (total * percentage1 - 1) get value1, and so on. + /// Remainder items go to buckets with the largest fractional parts, + /// not unconditionally to the last bucket. /// /// Zero-based index of the item. - /// Total number of items being distributed. For best accuracy, use totals >= 100. + /// Total number of items being distributed. /// The value assigned to this index position. public T Select(int index, int total) { var cumulative = 0; - foreach (var (value, percentage) in _buckets) + foreach (var (value, count) in GetCounts(total)) { - cumulative += (int)(total * percentage); + cumulative += count; if (index < cumulative) { return value; } } + return _buckets[^1].Value; } /// /// Returns all values with their calculated counts for a given total. - /// The last bucket receives any remainder from rounding. + /// Each bucket gets its truncated share, then the deficit is distributed one-at-a-time + /// to buckets with the largest fractional remainders. + /// Zero-weight buckets always receive exactly zero items. /// /// Total number of items to distribute. /// Sequence of value-count pairs. public IEnumerable<(T Value, int Count)> GetCounts(int total) { - var remaining = total; - for (var i = 0; i < _buckets.Length - 1; i++) + var counts = new int[_buckets.Length]; + var remainders = new double[_buckets.Length]; + var allocated = 0; + + for (var i = 0; i < _buckets.Length; i++) + { + var exact = total * _buckets[i].Percentage; + counts[i] = (int)exact; + remainders[i] = exact - counts[i]; + allocated += counts[i]; + } + + var deficit = total - allocated; + for (var d = 0; d < deficit; d++) + { + var bestIdx = 0; + for (var i = 1; i < remainders.Length; i++) + { + if (remainders[i] > remainders[bestIdx]) + { + bestIdx = i; + } + } + + counts[bestIdx]++; + remainders[bestIdx] = -1.0; + } + + for (var i = 0; i < _buckets.Length; i++) { - var count = (int)(total * _buckets[i].Percentage); - yield return (_buckets[i].Value, count); - remaining -= count; + yield return (_buckets[i].Value, counts[i]); } - yield return (_buckets[^1].Value, remaining); } } diff --git a/util/Seeder/Data/Distributions/FolderCountDistributions.cs b/util/Seeder/Data/Distributions/FolderCountDistributions.cs index c8811f2aa8ee..73c4291571c6 100644 --- a/util/Seeder/Data/Distributions/FolderCountDistributions.cs +++ b/util/Seeder/Data/Distributions/FolderCountDistributions.cs @@ -7,7 +7,7 @@ public static class FolderCountDistributions { /// /// Realistic distribution of folders per user. - /// 35% have zero, 35% have 1-3, 20% have 4-7, 10% have 10-15. + /// 35% have 0-1, 35% have 1-4, 20% have 4-8, 10% have 10-16. /// Values are (Min, Max) ranges for deterministic selection. /// public static Distribution<(int Min, int Max)> Realistic { get; } = new( @@ -16,4 +16,23 @@ public static class FolderCountDistributions ((4, 8), 0.20), ((10, 16), 0.10) ); + + /// + /// Enterprise: more structured organizations with heavier folder usage. + /// + public static Distribution<(int Min, int Max)> Enterprise { get; } = new( + ((0, 1), 0.20), + ((2, 5), 0.30), + ((5, 10), 0.30), + ((10, 25), 0.20) + ); + + /// + /// Minimal: most users don't bother organizing into folders. + /// + public static Distribution<(int Min, int Max)> Minimal { get; } = new( + ((0, 1), 0.70), + ((1, 3), 0.25), + ((3, 6), 0.05) + ); } diff --git a/util/Seeder/Data/Distributions/PersonalCipherDistributions.cs b/util/Seeder/Data/Distributions/PersonalCipherDistributions.cs new file mode 100644 index 000000000000..d2a7f2df61e2 --- /dev/null +++ b/util/Seeder/Data/Distributions/PersonalCipherDistributions.cs @@ -0,0 +1,37 @@ +namespace Bit.Seeder.Data.Distributions; + +/// +/// Pre-configured personal cipher count distributions per user. +/// +public static class PersonalCipherDistributions +{ + /// + /// Realistic enterprise mix: 30% have none, power users have 50-200. + /// + public static Distribution<(int Min, int Max)> Realistic { get; } = new( + ((0, 1), 0.30), + ((1, 5), 0.25), + ((5, 15), 0.25), + ((15, 50), 0.15), + ((50, 200), 0.05) + ); + + /// + /// Light usage: most users don't use personal vaults. + /// + public static Distribution<(int Min, int Max)> LightUsage { get; } = new( + ((0, 1), 0.60), + ((1, 5), 0.30), + ((5, 15), 0.10) + ); + + /// + /// Heavy usage: power users dominate, everyone has personal items. + /// + public static Distribution<(int Min, int Max)> HeavyUsage { get; } = new( + ((1, 5), 0.10), + ((5, 20), 0.30), + ((20, 100), 0.40), + ((100, 500), 0.20) + ); +} diff --git a/util/Seeder/Models/SeedPresetDensity.cs b/util/Seeder/Models/SeedPresetDensity.cs index 22adeb9b59a4..9d033a2372ed 100644 --- a/util/Seeder/Models/SeedPresetDensity.cs +++ b/util/Seeder/Models/SeedPresetDensity.cs @@ -14,6 +14,62 @@ internal record SeedPresetDensity public SeedPresetPermissions? Permissions { get; init; } public SeedPresetCipherAssignment? CipherAssignment { get; init; } + + public SeedPresetUserCollections? UserCollections { get; init; } + + public SeedPresetCipherTypes? CipherTypes { get; init; } + + public SeedPresetDensityPersonalCiphers? PersonalCiphers { get; init; } + + public SeedPresetDensityFolders? Folders { get; init; } +} + +/// +/// Folder count distribution per user: a named preset shape. +/// +internal record SeedPresetDensityFolders +{ + public string? Shape { get; init; } +} + +/// +/// Personal cipher count distribution per user: a named preset shape. +/// +internal record SeedPresetDensityPersonalCiphers +{ + public string? Shape { get; init; } +} + +/// +/// Cipher type distribution: a named preset or custom weights per type. +/// +internal record SeedPresetCipherTypes +{ + public string? Preset { get; init; } + + public double? Login { get; init; } + + public double? SecureNote { get; init; } + + public double? Card { get; init; } + + public double? Identity { get; init; } + + public double? SshKey { get; init; } +} + +/// +/// How many direct collections each user receives: range, distribution shape, and skew. +/// +internal record SeedPresetUserCollections +{ + public int? Min { get; init; } + + public int? Max { get; init; } + + public string? Shape { get; init; } + + public double? Skew { get; init; } } /// @@ -62,4 +118,8 @@ internal record SeedPresetCipherAssignment public string? Skew { get; init; } public double? OrphanRate { get; init; } + + public double? MultiCollectionRate { get; init; } + + public int? MaxCollectionsPerCipher { get; init; } } diff --git a/util/Seeder/Options/DensityProfile.cs b/util/Seeder/Options/DensityProfile.cs index 0b6317bef412..c98aa9e57318 100644 --- a/util/Seeder/Options/DensityProfile.cs +++ b/util/Seeder/Options/DensityProfile.cs @@ -1,4 +1,5 @@ -using Bit.Seeder.Data.Distributions; +using Bit.Core.Vault.Enums; +using Bit.Seeder.Data.Distributions; using Bit.Seeder.Data.Enums; namespace Bit.Seeder.Options; @@ -50,6 +51,31 @@ public class DensityProfile /// public Distribution PermissionDistribution { get; init; } = PermissionDistributions.Enterprise; + /// + /// Minimum direct collections per user. + /// + public int UserCollectionMin { get; init; } = 1; + + /// + /// Maximum direct collections per user. + /// + public int UserCollectionMax { get; init; } = 3; + + /// + /// Distribution shape for user-to-collection direct assignments. + /// + public CollectionFanOutShape UserCollectionShape { get; init; } = CollectionFanOutShape.Uniform; + + /// + /// Skew intensity for PowerLaw user-collection shape (0.0-1.0). Ignored for Uniform/FrontLoaded. + /// + public double UserCollectionSkew { get; init; } + + /// + /// Cipher type distribution override. When null, falls through to Realistic. + /// + public Distribution? CipherTypeDistribution { get; init; } + /// /// Cipher-to-collection assignment skew shape. /// @@ -59,4 +85,24 @@ public class DensityProfile /// Fraction of org ciphers with no collection assignment (0.0-1.0). /// public double OrphanCipherRate { get; init; } + + /// + /// Fraction of non-orphan ciphers assigned to more than one collection (0.0-1.0). + /// + public double MultiCollectionRate { get; init; } + + /// + /// Maximum number of collections a multi-collection cipher can belong to. + /// + public int MaxCollectionsPerCipher { get; init; } = 2; + + /// + /// Personal cipher count distribution override. When null, uses flat countPerUser. + /// + public Distribution<(int Min, int Max)>? PersonalCipherDistribution { get; init; } + + /// + /// Folder count distribution override. When null, uses FolderCountDistributions.Realistic. + /// + public Distribution<(int Min, int Max)>? FolderDistribution { get; init; } } diff --git a/util/Seeder/Pipeline/PresetLoader.cs b/util/Seeder/Pipeline/PresetLoader.cs index b2ce21f49398..992cf46df23b 100644 --- a/util/Seeder/Pipeline/PresetLoader.cs +++ b/util/Seeder/Pipeline/PresetLoader.cs @@ -1,4 +1,5 @@ -using Bit.Seeder.Data.Distributions; +using Bit.Core.Vault.Enums; +using Bit.Seeder.Data.Distributions; using Bit.Seeder.Data.Enums; using Bit.Seeder.Factories; using Bit.Seeder.Models; @@ -75,8 +76,15 @@ private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReade builder.AddOwner(); } + var density = ParseDensity(preset.Density); + // Generator requires a domain and is needed for generated ciphers, personal ciphers, or folders - if (domain is not null && (preset.Ciphers?.Count > 0 || preset.PersonalCiphers?.CountPerUser > 0 || preset.Folders == true)) + if (domain is not null && ( + preset.Ciphers?.Count > 0 || + preset.PersonalCiphers?.CountPerUser > 0 || + preset.Folders == true || + density?.FolderDistribution is not null || + density?.PersonalCipherDistribution is not null)) { builder.WithGenerator(domain); } @@ -86,8 +94,6 @@ private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReade builder.AddUsers(preset.Users.Count, preset.Users.RealisticStatusMix); } - var density = ParseDensity(preset.Density); - if (preset.Groups is not null) { builder.AddGroups(preset.Groups.Count, density); @@ -98,9 +104,9 @@ private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReade builder.AddCollections(preset.Collections.Count, density); } - if (preset.Folders == true) + if (preset.Folders == true || density?.FolderDistribution is not null) { - builder.AddFolders(); + builder.AddFolders(density); } if (preset.Ciphers?.Fixture is not null) @@ -114,7 +120,11 @@ private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReade if (preset.PersonalCiphers is not null && preset.PersonalCiphers.CountPerUser > 0) { - builder.AddPersonalCiphers(preset.PersonalCiphers.CountPerUser); + builder.AddPersonalCiphers(preset.PersonalCiphers.CountPerUser, density: density); + } + else if (density?.PersonalCipherDistribution is not null) + { + builder.AddPersonalCiphers(0, density: density); } builder.Validate(); @@ -139,6 +149,15 @@ private static void BuildRecipe(string presetName, SeedPreset preset, ISeedReade PermissionDistribution = ParsePermissions(preset.Permissions), CipherSkew = ParseEnum(preset.CipherAssignment?.Skew, CipherCollectionSkew.Uniform), OrphanCipherRate = preset.CipherAssignment?.OrphanRate ?? 0, + MultiCollectionRate = preset.CipherAssignment?.MultiCollectionRate ?? 0, + MaxCollectionsPerCipher = preset.CipherAssignment?.MaxCollectionsPerCipher ?? 2, + UserCollectionMin = preset.UserCollections?.Min ?? 1, + UserCollectionMax = preset.UserCollections?.Max ?? 3, + UserCollectionShape = ParseEnum(preset.UserCollections?.Shape, CollectionFanOutShape.Uniform), + UserCollectionSkew = preset.UserCollections?.Skew ?? 0, + CipherTypeDistribution = ParseCipherTypes(preset.CipherTypes), + PersonalCipherDistribution = ParsePersonalCipherDistribution(preset.PersonalCiphers?.Shape), + FolderDistribution = ParseFolderDistribution(preset.Folders?.Shape), }; } @@ -167,6 +186,88 @@ private static Distribution ParsePermissions(SeedPresetPermiss (PermissionWeight.HidePasswords, hidePasswords)); } - private static T ParseEnum(string? value, T defaultValue) where T : struct, Enum => - value is not null && Enum.TryParse(value, ignoreCase: true, out var result) ? result : defaultValue; + private static Distribution? ParseCipherTypes(SeedPresetCipherTypes? cipherTypes) + { + if (cipherTypes is null) + { + return null; + } + + if (cipherTypes.Preset is not null) + { + return cipherTypes.Preset.ToLowerInvariant() switch + { + "realistic" => CipherTypeDistributions.Realistic, + "loginonly" => CipherTypeDistributions.LoginOnly, + "documentationheavy" => CipherTypeDistributions.DocumentationHeavy, + "developerfocused" => CipherTypeDistributions.DeveloperFocused, + _ => throw new InvalidOperationException( + $"Unknown cipher type preset '{cipherTypes.Preset}'. Valid values: realistic, loginOnly, documentationHeavy, developerFocused."), + }; + } + + var login = cipherTypes.Login ?? 0; + var secureNote = cipherTypes.SecureNote ?? 0; + var card = cipherTypes.Card ?? 0; + var identity = cipherTypes.Identity ?? 0; + var sshKey = cipherTypes.SshKey ?? 0; + + return new Distribution( + (CipherType.Login, login), + (CipherType.SecureNote, secureNote), + (CipherType.Card, card), + (CipherType.Identity, identity), + (CipherType.SSHKey, sshKey)); + } + + private static Distribution<(int Min, int Max)>? ParsePersonalCipherDistribution(string? shape) + { + if (shape is null) + { + return null; + } + + return shape.ToLowerInvariant() switch + { + "realistic" => PersonalCipherDistributions.Realistic, + "lightusage" => PersonalCipherDistributions.LightUsage, + "heavyusage" => PersonalCipherDistributions.HeavyUsage, + _ => throw new InvalidOperationException( + $"Unknown personal cipher distribution '{shape}'. Valid values: realistic, lightUsage, heavyUsage."), + }; + } + + private static Distribution<(int Min, int Max)>? ParseFolderDistribution(string? shape) + { + if (shape is null) + { + return null; + } + + return shape.ToLowerInvariant() switch + { + "realistic" => FolderCountDistributions.Realistic, + "enterprise" => FolderCountDistributions.Enterprise, + "minimal" => FolderCountDistributions.Minimal, + _ => throw new InvalidOperationException( + $"Unknown folder distribution '{shape}'. Valid values: realistic, enterprise, minimal."), + }; + } + + private static T ParseEnum(string? value, T defaultValue) where T : struct, Enum + { + if (value is null) + { + return defaultValue; + } + + if (!Enum.TryParse(value, ignoreCase: true, out var result)) + { + var valid = string.Join(", ", Enum.GetNames()); + throw new InvalidOperationException( + $"Unknown {typeof(T).Name} '{value}'. Valid values: {valid}."); + } + + return result; + } } diff --git a/util/Seeder/Pipeline/RecipeBuilderExtensions.cs b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs index 58a93fd4f6d9..7922105c7447 100644 --- a/util/Seeder/Pipeline/RecipeBuilderExtensions.cs +++ b/util/Seeder/Pipeline/RecipeBuilderExtensions.cs @@ -126,6 +126,7 @@ public static RecipeBuilder AddUsers(this RecipeBuilder builder, int count, bool /// /// The recipe builder /// Number of groups to generate + /// Optional density profile for membership distribution control /// The builder for fluent chaining /// Thrown when no users exist public static RecipeBuilder AddGroups(this RecipeBuilder builder, int count, DensityProfile? density = null) @@ -145,6 +146,7 @@ public static RecipeBuilder AddGroups(this RecipeBuilder builder, int count, Den /// /// The recipe builder /// Number of collections to generate + /// Optional density profile for collection fan-out and permission control /// The builder for fluent chaining /// Thrown when no users exist public static RecipeBuilder AddCollections(this RecipeBuilder builder, int count, DensityProfile? density = null) @@ -179,9 +181,13 @@ public static RecipeBuilder AddCollections(this RecipeBuilder builder, OrgStruct } /// - /// Generate folders for each user using a realistic distribution. + /// Generate folders for each user using a configurable distribution. /// - public static RecipeBuilder AddFolders(this RecipeBuilder builder) + /// The recipe builder + /// Optional density profile for folder count distribution override + /// The builder for fluent chaining + /// Thrown when no users exist + public static RecipeBuilder AddFolders(this RecipeBuilder builder, DensityProfile? density = null) { if (!builder.HasRosterUsers && !builder.HasGeneratedUsers) { @@ -190,7 +196,7 @@ public static RecipeBuilder AddFolders(this RecipeBuilder builder) } builder.HasFolders = true; - builder.AddStep(_ => new GenerateFoldersStep()); + builder.AddStep(_ => new GenerateFoldersStep(density)); return builder; } @@ -222,6 +228,7 @@ public static RecipeBuilder UseCiphers(this RecipeBuilder builder, string fixtur /// Distribution of cipher types. Uses realistic defaults if null. /// Distribution of password strengths. Uses realistic defaults if null. /// When true, assigns ciphers to user folders round-robin. + /// Optional density profile for cipher-to-collection assignment control /// The builder for fluent chaining /// Thrown when UseCiphers() was already called public static RecipeBuilder AddCiphers( @@ -254,12 +261,14 @@ public static RecipeBuilder AddCiphers( /// Number of personal ciphers per user /// Distribution of cipher types. Uses realistic defaults if null. /// Distribution of password strengths. Uses realistic defaults if null. + /// Optional density profile for per-user personal cipher count distribution /// The builder for fluent chaining /// Thrown when no users exist public static RecipeBuilder AddPersonalCiphers( this RecipeBuilder builder, int countPerUser, Distribution? typeDist = null, - Distribution? pwDist = null) + Distribution? pwDist = null, + DensityProfile? density = null) { if (!builder.HasRosterUsers && !builder.HasGeneratedUsers) { @@ -268,7 +277,7 @@ public static RecipeBuilder AddPersonalCiphers( } builder.HasPersonalCiphers = true; - builder.AddStep(_ => new GeneratePersonalCiphersStep(countPerUser, typeDist, pwDist)); + builder.AddStep(_ => new GeneratePersonalCiphersStep(countPerUser, typeDist, pwDist, density)); return builder; } diff --git a/util/Seeder/Pipeline/RecipeOrchestrator.cs b/util/Seeder/Pipeline/RecipeOrchestrator.cs index 1cebfe210f46..8f919f80cae8 100644 --- a/util/Seeder/Pipeline/RecipeOrchestrator.cs +++ b/util/Seeder/Pipeline/RecipeOrchestrator.cs @@ -82,7 +82,7 @@ internal ExecutionResult Execute( if (options.Ciphers > 0) { - builder.AddFolders(); + builder.AddFolders(options.Density); builder.AddCiphers(options.Ciphers, options.CipherTypeDistribution, options.PasswordDistribution, density: options.Density); } diff --git a/util/Seeder/Seeds/schemas/preset.schema.json b/util/Seeder/Seeds/schemas/preset.schema.json index f8a6cdced388..64067f499137 100644 --- a/util/Seeder/Seeds/schemas/preset.schema.json +++ b/util/Seeder/Seeds/schemas/preset.schema.json @@ -280,6 +280,112 @@ "minimum": 0.0, "maximum": 1.0, "description": "Fraction of org ciphers with no collection assignment." + }, + "multiCollectionRate": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Fraction of non-orphan ciphers assigned to more than one collection. Default: 0." + }, + "maxCollectionsPerCipher": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "description": "Maximum number of collections a multi-collection cipher can belong to. Default: 2." + } + } + }, + "userCollections": { + "type": "object", + "description": "How many direct collections each user receives via CollectionUser records.", + "additionalProperties": false, + "properties": { + "min": { + "type": "integer", + "minimum": 1, + "description": "Minimum direct collections per user. Default: 1." + }, + "max": { + "type": "integer", + "minimum": 1, + "description": "Maximum direct collections per user. Default: 3." + }, + "shape": { + "type": "string", + "enum": ["uniform", "powerLaw", "frontLoaded"], + "description": "Distribution shape for user-collection assignments. Default: uniform." + }, + "skew": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Skew intensity for powerLaw shape. Ignored for uniform/frontLoaded. Default: 0." + } + } + }, + "cipherTypes": { + "type": "object", + "description": "Cipher type distribution for generated ciphers. Use 'preset' for a named distribution or specify custom weights per type.", + "additionalProperties": false, + "properties": { + "preset": { + "type": "string", + "enum": ["realistic", "loginOnly", "documentationHeavy", "developerFocused"], + "description": "Named cipher type distribution. Mutually exclusive with custom weights." + }, + "login": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Weight for Login ciphers. All custom weights must sum to 1.0." + }, + "secureNote": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Weight for SecureNote ciphers." + }, + "card": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Weight for Card ciphers." + }, + "identity": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Weight for Identity ciphers." + }, + "sshKey": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "Weight for SSH Key ciphers." + } + } + }, + "personalCiphers": { + "type": "object", + "description": "Personal cipher count distribution per user. Overrides the top-level personalCiphers.countPerUser with a variable distribution.", + "additionalProperties": false, + "properties": { + "shape": { + "type": "string", + "enum": ["realistic", "lightUsage", "heavyUsage"], + "description": "Named distribution for personal cipher counts per user. Default: flat countPerUser." + } + } + }, + "folders": { + "type": "object", + "description": "Folder count distribution per user. Overrides the default realistic distribution.", + "additionalProperties": false, + "properties": { + "shape": { + "type": "string", + "enum": ["realistic", "enterprise", "minimal"], + "description": "Named folder count distribution. Default: realistic." } } } diff --git a/util/Seeder/Steps/CreateCollectionsStep.cs b/util/Seeder/Steps/CreateCollectionsStep.cs index 9747e2eb79fd..14fabc59acf6 100644 --- a/util/Seeder/Steps/CreateCollectionsStep.cs +++ b/util/Seeder/Steps/CreateCollectionsStep.cs @@ -135,7 +135,6 @@ internal int ComputeFanOut(int collectionIndex, int collectionCount, int min, in return min + (int)(weight * (range - 1) + 0.5); case CollectionFanOutShape.FrontLoaded: - // First 10% of collections get max fan-out, rest get min var topCount = Math.Max(1, collectionCount / 10); return collectionIndex < topCount ? max : min; @@ -148,14 +147,18 @@ internal int ComputeFanOut(int collectionIndex, int collectionCount, int min, in } } - internal static List BuildCollectionUsers( + internal List BuildCollectionUsers( List collectionIds, List userIds, int directUserCount) { - var result = new List(directUserCount * 2); + var min = _density!.UserCollectionMin; + var max = _density.UserCollectionMax; + var result = new List(directUserCount * (min + max + 1) / 2); for (var i = 0; i < directUserCount; i++) { - var maxAssignments = Math.Min((i % 3) + 1, collectionIds.Count); - for (var j = 0; j < maxAssignments; j++) + var assignmentCount = Math.Min( + ComputeCollectionsPerUser(i, directUserCount, min, max), + collectionIds.Count); + for (var j = 0; j < assignmentCount; j++) { result.Add(CollectionUserSeeder.Create( collectionIds[(i + j) % collectionIds.Count], @@ -165,6 +168,34 @@ internal static List BuildCollectionUsers( return result; } + internal int ComputeCollectionsPerUser(int userIndex, int userCount, int min, int max) + { + var range = max - min + 1; + if (range <= 1) + { + return min; + } + + switch (_density!.UserCollectionShape) + { + case CollectionFanOutShape.PowerLaw: + var exponent = 0.5 + _density.UserCollectionSkew * 1.5; + var weight = 1.0 / Math.Pow(userIndex + 1, exponent); + return min + (int)(weight * (range - 1) + 0.5); + + case CollectionFanOutShape.FrontLoaded: + var topCount = Math.Max(1, userCount / 10); + return userIndex < topCount ? max : min; + + case CollectionFanOutShape.Uniform: + return min + (userIndex % range); + + default: + throw new InvalidOperationException( + $"Unhandled CollectionFanOutShape: {_density.UserCollectionShape}"); + } + } + private static (bool ReadOnly, bool HidePasswords, bool Manage) ResolvePermission( Distribution distribution, int index, int total) { diff --git a/util/Seeder/Steps/GenerateCiphersStep.cs b/util/Seeder/Steps/GenerateCiphersStep.cs index 517750db65da..5becbfaadfbb 100644 --- a/util/Seeder/Steps/GenerateCiphersStep.cs +++ b/util/Seeder/Steps/GenerateCiphersStep.cs @@ -43,7 +43,7 @@ public void Execute(SeederContext context) var orgId = context.RequireOrgId(); var orgKey = context.RequireOrgKey(); var collectionIds = context.Registry.CollectionIds; - var typeDistribution = typeDist ?? CipherTypeDistributions.Realistic; + var typeDistribution = typeDist ?? _density?.CipherTypeDistribution ?? CipherTypeDistributions.Realistic; var passwordDistribution = pwDist ?? PasswordDistributions.Realistic; var companies = Companies.All; @@ -95,6 +95,7 @@ public void Execute(SeederContext context) { var orphanCount = (int)(count * _density.OrphanCipherRate); var nonOrphanCount = count - orphanCount; + var primaryIndices = new int[nonOrphanCount]; for (var i = 0; i < nonOrphanCount; i++) { @@ -110,14 +111,33 @@ public void Execute(SeederContext context) collectionIndex = i % collectionIds.Count; } - var collectionId = collectionIds[collectionIndex]; + primaryIndices[i] = collectionIndex; collectionCiphers.Add(new CollectionCipher { CipherId = ciphers[i].Id, - CollectionId = collectionId + CollectionId = collectionIds[collectionIndex] }); } + + if (_density.MultiCollectionRate > 0 && collectionIds.Count > 1) + { + var multiCount = (int)(nonOrphanCount * _density.MultiCollectionRate); + for (var i = 0; i < multiCount; i++) + { + var extraCount = 1 + (i % Math.Max(_density.MaxCollectionsPerCipher - 1, 1)); + extraCount = Math.Min(extraCount, collectionIds.Count - 1); + for (var j = 0; j < extraCount; j++) + { + var secondaryIndex = (primaryIndices[i] + 1 + j) % collectionIds.Count; + collectionCiphers.Add(new CollectionCipher + { + CipherId = ciphers[i].Id, + CollectionId = collectionIds[secondaryIndex] + }); + } + } + } } } diff --git a/util/Seeder/Steps/GenerateFoldersStep.cs b/util/Seeder/Steps/GenerateFoldersStep.cs index bd856a780586..61293ba1c006 100644 --- a/util/Seeder/Steps/GenerateFoldersStep.cs +++ b/util/Seeder/Steps/GenerateFoldersStep.cs @@ -1,25 +1,26 @@ using Bit.Seeder.Data.Distributions; using Bit.Seeder.Factories; +using Bit.Seeder.Options; using Bit.Seeder.Pipeline; namespace Bit.Seeder.Steps; /// -/// Generates folders for each user based on a realistic distribution, encrypted with each user's symmetric key. +/// Generates folders for each user based on a configurable distribution, encrypted with each user's symmetric key. /// -internal sealed class GenerateFoldersStep : IStep +internal sealed class GenerateFoldersStep(DensityProfile? density = null) : IStep { public void Execute(SeederContext context) { var generator = context.RequireGenerator(); var userDigests = context.Registry.UserDigests; - var distribution = FolderCountDistributions.Realistic; + var distribution = density?.FolderDistribution ?? FolderCountDistributions.Realistic; for (var index = 0; index < userDigests.Count; index++) { var digest = userDigests[index]; var range = distribution.Select(index, userDigests.Count); - var count = range.Min + (index % Math.Max(range.Max - range.Min, 1)); + var count = range.Min + (index % Math.Max(range.Max - range.Min + 1, 1)); var folderIds = new List(count); for (var i = 0; i < count; i++) diff --git a/util/Seeder/Steps/GeneratePersonalCiphersStep.cs b/util/Seeder/Steps/GeneratePersonalCiphersStep.cs index a9e0391f2319..31c29c3a53a2 100644 --- a/util/Seeder/Steps/GeneratePersonalCiphersStep.cs +++ b/util/Seeder/Steps/GeneratePersonalCiphersStep.cs @@ -4,26 +4,29 @@ using Bit.Seeder.Data.Enums; using Bit.Seeder.Data.Static; using Bit.Seeder.Factories; +using Bit.Seeder.Options; using Bit.Seeder.Pipeline; namespace Bit.Seeder.Steps; /// -/// Creates N personal cipher entities per user, encrypted with each user's symmetric key. +/// Creates personal cipher entities per user, encrypted with each user's symmetric key. /// /// /// Iterates over and creates ciphers with /// UserId set and OrganizationId null. Personal ciphers are not assigned -/// to collections. +/// to collections. When a is set, +/// each user's count varies according to the distribution instead of using a flat count. /// internal sealed class GeneratePersonalCiphersStep( int countPerUser, Distribution? typeDist = null, - Distribution? pwDist = null) : IStep + Distribution? pwDist = null, + DensityProfile? density = null) : IStep { public void Execute(SeederContext context) { - if (countPerUser == 0) + if (countPerUser == 0 && density?.PersonalCipherDistribution is null) { return; } @@ -34,16 +37,28 @@ public void Execute(SeederContext context) var typeDistribution = typeDist ?? CipherTypeDistributions.Realistic; var passwordDistribution = pwDist ?? PasswordDistributions.Realistic; var companies = Companies.All; + var personalDist = density?.PersonalCipherDistribution; + var expectedTotal = personalDist is not null + ? EstimateTotal(userDigests.Count, personalDist) + : userDigests.Count * countPerUser; - var ciphers = new List(userDigests.Count * countPerUser); - var cipherIds = new List(userDigests.Count * countPerUser); + var ciphers = new List(expectedTotal); + var cipherIds = new List(expectedTotal); var globalIndex = 0; - foreach (var userDigest in userDigests) + for (var userIndex = 0; userIndex < userDigests.Count; userIndex++) { - for (var i = 0; i < countPerUser; i++) + var userDigest = userDigests[userIndex]; + var userCount = countPerUser; + if (personalDist is not null) { - var cipherType = typeDistribution.Select(globalIndex, userDigests.Count * countPerUser); + var range = personalDist.Select(userIndex, userDigests.Count); + userCount = range.Min + (userIndex % Math.Max(range.Max - range.Min + 1, 1)); + } + + for (var i = 0; i < userCount; i++) + { + var cipherType = typeDistribution.Select(globalIndex, expectedTotal); var cipher = CipherComposer.Compose(globalIndex, cipherType, userDigest.SymmetricKey, companies, generator, passwordDistribution, userId: userDigest.UserId); CipherComposer.AssignFolder(cipher, userDigest.UserId, i, context.Registry.UserFolderIds); @@ -57,4 +72,16 @@ public void Execute(SeederContext context) context.Ciphers.AddRange(ciphers); context.Registry.CipherIds.AddRange(cipherIds); } + + private static int EstimateTotal(int userCount, Distribution<(int Min, int Max)> dist) + { + var total = 0; + for (var i = 0; i < userCount; i++) + { + var range = dist.Select(i, userCount); + total += range.Min + (i % Math.Max(range.Max - range.Min + 1, 1)); + } + + return Math.Max(total, 1); + } } From 5794c3cfc6dd43419885f312bd75a292b2f34565 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:40:01 -0400 Subject: [PATCH 57/85] chore(flags): Remove pm-19394-send-access-control feature flag * Remove feature flag. * Fixed import statements. * Fixed constructor. --- src/Core/Constants.cs | 1 - .../SendAccess/SendAccessGrantValidator.cs | 12 +--- ...endAccessGrantValidatorIntegrationTests.cs | 67 +------------------ .../SendAccessGrantValidatorTests.cs | 40 +---------- 4 files changed, 6 insertions(+), 114 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ca15c6e26170..a86370d97c21 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -221,7 +221,6 @@ public static class FeatureFlagKeys public const string MobileErrorReporting = "mobile-error-reporting"; public const string AndroidChromeAutofill = "android-chrome-autofill"; public const string UserManagedPrivilegedApps = "pm-18970-user-managed-privileged-apps"; - public const string SendAccess = "pm-19394-send-access-control"; public const string CxpImportMobile = "cxp-import-mobile"; public const string CxpExportMobile = "cxp-export-mobile"; public const string DeviceAuthKey = "pm-27581-device-auth-key"; diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs index 101c6952f396..7607394b008f 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs @@ -1,7 +1,5 @@ using System.Security.Claims; -using Bit.Core; using Bit.Core.Auth.Identity; -using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; using Bit.Core.Utilities; @@ -15,8 +13,7 @@ public class SendAccessGrantValidator( ISendAuthenticationQuery _sendAuthenticationQuery, ISendAuthenticationMethodValidator _sendNeverAuthenticateValidator, ISendAuthenticationMethodValidator _sendPasswordRequestValidator, - ISendAuthenticationMethodValidator _sendEmailOtpRequestValidator, - IFeatureService _featureService) : IExtensionGrantValidator + ISendAuthenticationMethodValidator _sendEmailOtpRequestValidator) : IExtensionGrantValidator { string IExtensionGrantValidator.GrantType => CustomGrantTypes.SendAccess; @@ -28,13 +25,6 @@ public class SendAccessGrantValidator( public async Task ValidateAsync(ExtensionGrantValidationContext context) { - // Check the feature flag - if (!_featureService.IsEnabled(FeatureFlagKeys.SendAccess)) - { - context.Result = new GrantValidationResult(TokenRequestErrors.UnsupportedGrantType); - return; - } - var (sendIdGuid, result) = GetRequestSendId(context); if (result != SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid) { diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs index a6dd4b6b384d..1e81c77b6acc 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs @@ -1,6 +1,4 @@ -using Bit.Core; -using Bit.Core.Enums; -using Bit.Core.Services; +using Bit.Core.Enums; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; using Bit.Identity.IdentityServer.Enums; @@ -19,32 +17,6 @@ internal record AnUnknownAuthenticationMethod : SendAuthenticationMethod { } public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture { - [Fact] - public async Task SendAccessGrant_FeatureFlagDisabled_ReturnsUnsupportedGrantType() - { - // Arrange - var sendId = Guid.NewGuid(); - var client = _factory.WithWebHostBuilder(builder => - { - builder.ConfigureServices(services => - { - // Mock feature service to return false - var featureService = Substitute.For(); - featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(false); - services.AddSingleton(featureService); - }); - }).CreateClient(); - - var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); - - // Act - var response = await client.PostAsync("/connect/token", requestBody); - - // Assert - var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("unsupported_grant_type", content); - } - [Fact] public async Task SendAccessGrant_ValidNotAuthenticatedSend_ReturnsAccessToken() { @@ -54,11 +26,6 @@ public async Task SendAccessGrant_ValidNotAuthenticatedSend_ReturnsAccessToken() { builder.ConfigureServices(services => { - // Mock feature service to return true - var featureService = Substitute.For(); - featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true); - services.AddSingleton(featureService); - // Mock send authentication query var sendAuthQuery = Substitute.For(); sendAuthQuery.GetAuthenticationMethod(sendId).Returns(new NotAuthenticated()); @@ -82,15 +49,7 @@ public async Task SendAccessGrant_ValidNotAuthenticatedSend_ReturnsAccessToken() public async Task SendAccessGrant_MissingSendId_ReturnsInvalidRequest() { // Arrange - var client = _factory.WithWebHostBuilder(builder => - { - builder.ConfigureServices(services => - { - var featureService = Substitute.For(); - featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true); - services.AddSingleton(featureService); - }); - }).CreateClient(); + var client = _factory.CreateClient(); var requestBody = new FormUrlEncodedContent([ new KeyValuePair(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess), @@ -111,15 +70,7 @@ public async Task SendAccessGrant_EmptySendGuid_ReturnsInvalidGrant() { // Arrange var sendId = Guid.Empty; - var client = _factory.WithWebHostBuilder(builder => - { - builder.ConfigureServices(services => - { - var featureService = Substitute.For(); - featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true); - services.AddSingleton(featureService); - }); - }).CreateClient(); + var client = _factory.CreateClient(); var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); @@ -140,10 +91,6 @@ public async Task SendAccessGrant_NeverAuthenticateSend_ReturnsInvalidGrant() { builder.ConfigureServices(services => { - var featureService = Substitute.For(); - featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true); - services.AddSingleton(featureService); - var sendAuthQuery = Substitute.For(); sendAuthQuery.GetAuthenticationMethod(sendId).Returns(new NeverAuthenticate()); services.AddSingleton(sendAuthQuery); @@ -169,10 +116,6 @@ public async Task SendAccessGrant_UnknownAuthenticationMethod_ThrowsInvalidOpera { builder.ConfigureServices(services => { - var featureService = Substitute.For(); - featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true); - services.AddSingleton(featureService); - var sendAuthQuery = Substitute.For(); sendAuthQuery.GetAuthenticationMethod(sendId).Returns(new AnUnknownAuthenticationMethod()); services.AddSingleton(sendAuthQuery); @@ -200,10 +143,6 @@ public async Task SendAccessGrant_PasswordProtectedSend_CallsPasswordValidator() { builder.ConfigureServices(services => { - var featureService = Substitute.For(); - featureService.IsEnabled(FeatureFlagKeys.SendAccess).Returns(true); - services.AddSingleton(featureService); - var sendAuthQuery = Substitute.For(); sendAuthQuery.GetAuthenticationMethod(sendId).Returns(resourcePassword); services.AddSingleton(sendAuthQuery); diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs index 59d8dee2e267..92b33c0064da 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs @@ -1,6 +1,4 @@ -using Bit.Core; -using Bit.Core.Auth.Identity; -using Bit.Core.Services; +using Bit.Core.Auth.Identity; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; using Bit.Identity.IdentityServer.Enums; @@ -18,28 +16,6 @@ namespace Bit.Identity.Test.IdentityServer.SendAccess; [SutProviderCustomize] public class SendAccessGrantValidatorTests { - [Theory, BitAutoData] - public async Task ValidateAsync_FeatureFlagDisabled_ReturnsUnsupportedGrantType( - [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.SendAccess) - .Returns(false); - - var context = new ExtensionGrantValidationContext - { - Request = tokenRequest - }; - - // Act - await sutProvider.Sut.ValidateAsync(context); - - // Assert - Assert.True(context.Result.IsError); - Assert.Equal(OidcConstants.TokenErrors.UnsupportedGrantType, context.Result.Error); - } [Theory, BitAutoData] public async Task ValidateAsync_MissingSendId_ReturnsInvalidRequest( @@ -47,10 +23,6 @@ public async Task ValidateAsync_MissingSendId_ReturnsInvalidRequest( SutProvider sutProvider) { // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.SendAccess) - .Returns(true); - var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -70,10 +42,6 @@ public async Task ValidateAsync_InvalidSendId_ReturnsInvalidGrant( SutProvider sutProvider) { // Arrange - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.SendAccess) - .Returns(true); - var context = new ExtensionGrantValidationContext(); tokenRequest.GrantType = CustomGrantTypes.SendAccess; @@ -268,7 +236,7 @@ public async Task ValidateAsync_UnknownAuthMethod_ThrowsInvalidOperationExceptio public void GrantType_ReturnsCorrectType() { // Arrange & Act - var validator = new SendAccessGrantValidator(null!, null!, null!, null!, null!); + var validator = new SendAccessGrantValidator(null!, null!, null!, null!); // Assert Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType); @@ -286,10 +254,6 @@ private static ExtensionGrantValidationContext SetupTokenRequest( Guid sendId, ValidatedTokenRequest request) { - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.SendAccess) - .Returns(true); - var context = new ExtensionGrantValidationContext(); request.GrantType = CustomGrantTypes.SendAccess; From 222d9380f303636d53ffc81c5c7bed12aa3a4eba Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:02:50 -0500 Subject: [PATCH 58/85] [deps] Billing: Update coverlet.collector to v8 (#7118) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- test/Core.IntegrationTest/Core.IntegrationTest.csproj | 2 +- .../Infrastructure.Dapper.Test.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Core.IntegrationTest/Core.IntegrationTest.csproj b/test/Core.IntegrationTest/Core.IntegrationTest.csproj index 133793d3d831..05bd95667288 100644 --- a/test/Core.IntegrationTest/Core.IntegrationTest.csproj +++ b/test/Core.IntegrationTest/Core.IntegrationTest.csproj @@ -10,7 +10,7 @@ - + diff --git a/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj b/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj index 7a6bd3ba2053..8a7f0a79a553 100644 --- a/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj +++ b/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj @@ -14,7 +14,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 27a772ef83a3bc9515d93eddf8b3be100f679a58 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:24:07 -0700 Subject: [PATCH 59/85] [PM-32597] - create short-lived signed attachment URL for self-hosted instances (#7100) * create short-lived signed attachment URL for self-hosted instances * move local attachment logic to service * remove comment * remove unusued var. add happy-path test for file download --- .../Vault/Controllers/CiphersController.cs | 42 ++++++ .../Services/IAttachmentStorageService.cs | 10 ++ src/Core/Vault/Services/ICipherService.cs | 1 - .../AzureAttachmentStorageService.cs | 22 ++- .../Services/Implementations/CipherService.cs | 5 +- .../LocalAttachmentStorageService.cs | 57 +++++++- .../NoopAttachmentStorageService.cs | 10 ++ .../Controllers/CiphersControllerTests.cs | 136 ++++++++++++++++++ .../LocalAttachmentStorageServiceTests.cs | 85 +++++++++++ .../Vault/Services/CipherServiceTests.cs | 50 +++++++ 10 files changed, 404 insertions(+), 14 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 9ead8bc4bdca..03b545efeed5 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -1498,10 +1498,52 @@ public async Task GetAttachmentData(Guid id, string att { var userId = _userService.GetProperUserId(User).Value; var cipher = await GetByIdAsync(id, userId); + if (cipher == null) + { + throw new NotFoundException(); + } + var result = await _cipherService.GetAttachmentDownloadDataAsync(cipher, attachmentId); return new AttachmentResponseModel(result); } + /// + /// Serves a locally stored attachment file using a time-limited, signed token. + /// This endpoint replaces direct static file access for self-hosted environments + /// to ensure that only authorized users can download attachment files. + /// + [AllowAnonymous] + [HttpGet("attachment/download")] + public async Task DownloadAttachmentAsync([FromQuery] string token) + { + if (string.IsNullOrEmpty(token)) + { + throw new NotFoundException(); + } + + (Guid cipherId, string attachmentId) = _attachmentStorageService.ParseAttachmentDownloadToken(token); + + var cipher = await _cipherRepository.GetByIdAsync(cipherId); + if (cipher == null) + { + throw new NotFoundException(); + } + + var attachments = cipher.GetAttachments(); + if (attachments == null || !attachments.TryGetValue(attachmentId, out var attachmentData)) + { + throw new NotFoundException(); + } + + var stream = await _attachmentStorageService.GetAttachmentReadStreamAsync(cipher, attachmentData); + if (stream == null) + { + throw new NotFoundException(); + } + + return File(stream, "application/octet-stream", attachmentData.FileName); + } + [HttpPost("{id}/attachment/{attachmentId}/share")] [RequestSizeLimit(Constants.FileSize101mb)] [DisableFormValueModelBinding] diff --git a/src/Core/Services/IAttachmentStorageService.cs b/src/Core/Services/IAttachmentStorageService.cs index 7c19ce321aa8..1f40437b7f9c 100644 --- a/src/Core/Services/IAttachmentStorageService.cs +++ b/src/Core/Services/IAttachmentStorageService.cs @@ -19,5 +19,15 @@ public interface IAttachmentStorageService Task DeleteAttachmentsForUserAsync(Guid userId); Task GetAttachmentUploadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData); Task GetAttachmentDownloadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData); + /// + /// Parses and validates a time-limited download token, returning the cipher ID and attachment ID. + /// Only supported by storage implementations that use signed URLs (e.g. local/self-hosted storage). + /// + (Guid cipherId, string attachmentId) ParseAttachmentDownloadToken(string token); + /// + /// Opens a read stream for a locally stored attachment file. + /// Returns null if the storage implementation does not support direct streaming (e.g. cloud storage). + /// + Task GetAttachmentReadStreamAsync(Cipher cipher, CipherAttachment.MetaData attachmentData); Task<(bool, long?)> ValidateFileAsync(Cipher cipher, CipherAttachment.MetaData attachmentData, long leeway); } diff --git a/src/Core/Vault/Services/ICipherService.cs b/src/Core/Vault/Services/ICipherService.cs index 765dae30c147..da01b55ab174 100644 --- a/src/Core/Vault/Services/ICipherService.cs +++ b/src/Core/Vault/Services/ICipherService.cs @@ -3,7 +3,6 @@ using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; - namespace Bit.Core.Vault.Services; public interface ICipherService diff --git a/src/Core/Vault/Services/Implementations/AzureAttachmentStorageService.cs b/src/Core/Vault/Services/Implementations/AzureAttachmentStorageService.cs index d03a7e5fcf96..ed5d47e52696 100644 --- a/src/Core/Vault/Services/Implementations/AzureAttachmentStorageService.cs +++ b/src/Core/Vault/Services/Implementations/AzureAttachmentStorageService.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Azure.Storage.Blobs; +using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Azure.Storage.Sas; using Bit.Core.Enums; @@ -32,7 +29,7 @@ private string BlobName(Guid cipherId, CipherAttachment.MetaData attachmentData, attachmentData.AttachmentId ); - public static (string cipherId, string organizationId, string attachmentId) IdentifiersFromBlobName(string blobName) + public static (string cipherId, string? organizationId, string attachmentId) IdentifiersFromBlobName(string blobName) { var parts = blobName.Split('/'); switch (parts.Length) @@ -71,6 +68,11 @@ public async Task GetAttachmentDownloadUrlAsync(Cipher cipher, CipherAtt return sasUri.ToString(); } + public (Guid cipherId, string attachmentId) ParseAttachmentDownloadToken(string token) + { + throw new NotSupportedException("Token-based downloads are not supported with Azure storage."); + } + public async Task GetAttachmentUploadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) { await InitAsync(EventGridEnabledContainerName); @@ -94,7 +96,7 @@ public async Task UploadNewAttachmentAsync(Stream stream, Cipher cipher, CipherA } else { - metadata.Add("organizationId", cipher.OrganizationId.Value.ToString()); + metadata.Add("organizationId", cipher.OrganizationId!.Value.ToString()); } var headers = new BlobHttpHeaders @@ -193,6 +195,12 @@ public async Task DeleteAttachmentsForUserAsync(Guid userId) await InitAsync(_defaultContainerName); } + public Task GetAttachmentReadStreamAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) + { + // Azure storage uses SAS URLs for downloads; direct streaming is not supported. + return Task.FromResult(null); + } + public async Task<(bool, long?)> ValidateFileAsync(Cipher cipher, CipherAttachment.MetaData attachmentData, long leeway) { await InitAsync(attachmentData.ContainerName); @@ -211,7 +219,7 @@ public async Task DeleteAttachmentsForUserAsync(Guid userId) } else { - metadata["organizationId"] = cipher.OrganizationId.Value.ToString(); + metadata["organizationId"] = cipher.OrganizationId!.Value.ToString(); } await blobClient.SetMetadataAsync(metadata); diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 3a970d82bdff..5f235879c676 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -21,7 +21,6 @@ using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Queries; using Bit.Core.Vault.Repositories; - namespace Bit.Core.Vault.Services; public class CipherService : ICipherService @@ -412,12 +411,14 @@ public async Task GetAttachmentDownloadDataAsync(Cipher throw new NotFoundException(); } + var url = await _attachmentStorageService.GetAttachmentDownloadUrlAsync(cipher, data); + var response = new AttachmentResponseData { Cipher = cipher, Data = data, Id = attachmentId, - Url = await _attachmentStorageService.GetAttachmentDownloadUrlAsync(cipher, data), + Url = url, }; return response; diff --git a/src/Core/Vault/Services/Implementations/LocalAttachmentStorageService.cs b/src/Core/Vault/Services/Implementations/LocalAttachmentStorageService.cs index ac3c4a796d3e..f5894b5f35e0 100644 --- a/src/Core/Vault/Services/Implementations/LocalAttachmentStorageService.cs +++ b/src/Core/Vault/Services/Implementations/LocalAttachmentStorageService.cs @@ -1,31 +1,69 @@ using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; +using Microsoft.AspNetCore.DataProtection; namespace Bit.Core.Vault.Services; public class LocalAttachmentStorageService : IAttachmentStorageService { - private readonly string _baseAttachmentUrl; private readonly string _baseDirPath; private readonly string _baseTempDirPath; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly string _apiBaseUrl; + + internal static readonly string AttachmentDownloadProtectorPurpose = "AttachmentDownload"; + private static readonly TimeSpan _downloadLinkLifetime = TimeSpan.FromMinutes(1); public FileUploadType FileUploadType => FileUploadType.Direct; public LocalAttachmentStorageService( - IGlobalSettings globalSettings) + IGlobalSettings globalSettings, + IDataProtectionProvider dataProtectionProvider) { _baseDirPath = globalSettings.Attachment.BaseDirectory; _baseTempDirPath = $"{_baseDirPath}/temp"; - _baseAttachmentUrl = globalSettings.Attachment.BaseUrl; + _dataProtectionProvider = dataProtectionProvider; + _apiBaseUrl = globalSettings.BaseServiceUri.Api; } public async Task GetAttachmentDownloadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) { await InitAsync(); - return $"{_baseAttachmentUrl}/{cipher.Id}/{attachmentData.AttachmentId}"; + var protector = _dataProtectionProvider.CreateProtector(AttachmentDownloadProtectorPurpose); + var timedProtector = protector.ToTimeLimitedDataProtector(); + var token = timedProtector.Protect( + $"{cipher.Id}|{attachmentData.AttachmentId}", + _downloadLinkLifetime); + return $"{_apiBaseUrl}/ciphers/attachment/download?token={Uri.EscapeDataString(token)}"; + } + + public (Guid cipherId, string attachmentId) ParseAttachmentDownloadToken(string token) + { + var protector = _dataProtectionProvider + .CreateProtector(AttachmentDownloadProtectorPurpose) + .ToTimeLimitedDataProtector(); + + string payload; + try + { + payload = protector.Unprotect(token); + } + catch + { + throw new NotFoundException(); + } + + var parts = payload.Split('|'); + if (parts.Length != 2 || !Guid.TryParse(parts[0], out var cipherId)) + { + throw new NotFoundException(); + } + + return (cipherId, parts[1]); } public async Task UploadNewAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentData) @@ -174,6 +212,17 @@ private string AttachmentFilePath(string attachmentId, Guid cipherId, Guid? orga organizationId.HasValue ? AttachmentFilePath(OrganizationDirectoryPath(cipherId, organizationId.Value, temp), attachmentId) : AttachmentFilePath(CipherDirectoryPath(cipherId, temp), attachmentId); + public Task GetAttachmentReadStreamAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) + { + var path = AttachmentFilePath(attachmentData.AttachmentId, cipher.Id, temp: false); + if (!File.Exists(path)) + { + return Task.FromResult(null); + } + + return Task.FromResult(File.OpenRead(path)); + } + public Task GetAttachmentUploadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) => Task.FromResult($"{cipher.Id}/attachment/{attachmentData.AttachmentId}"); diff --git a/src/Core/Vault/Services/NoopImplementations/NoopAttachmentStorageService.cs b/src/Core/Vault/Services/NoopImplementations/NoopAttachmentStorageService.cs index 8014849d93ad..22b3d0677bae 100644 --- a/src/Core/Vault/Services/NoopImplementations/NoopAttachmentStorageService.cs +++ b/src/Core/Vault/Services/NoopImplementations/NoopAttachmentStorageService.cs @@ -62,6 +62,16 @@ public Task GetAttachmentDownloadUrlAsync(Cipher cipher, CipherAttachmen return Task.FromResult((string)null); } + public (Guid cipherId, string attachmentId) ParseAttachmentDownloadToken(string token) + { + throw new NotSupportedException("Token-based downloads are not supported with noop storage."); + } + + public Task GetAttachmentReadStreamAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) + { + return Task.FromResult(null); + } + public Task GetAttachmentUploadUrlAsync(Cipher cipher, CipherAttachment.MetaData attachmentData) { return Task.FromResult(default(string)); diff --git a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs index 6fba9730a73b..80a4e3571f29 100644 --- a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs @@ -18,7 +18,9 @@ using Bit.Core.Vault.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; using NSubstitute; +using NSubstitute.ExceptionExtensions; using NSubstitute.ReturnsExtensions; using Xunit; using CipherType = Bit.Core.Vault.Enums.CipherType; @@ -2152,4 +2154,138 @@ public async Task PutShare_UpdateExistingFolderAndFavorite_UpdatesUserSpecificFi Assert.Equal(newFolderId, result.FolderId); Assert.True(result.Favorite); } + + [Theory, BitAutoData] + public async Task GetAttachmentData_CipherNotFound_ThrowsNotFoundException( + Guid cipherId, string attachmentId, Guid userId, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs((Guid?)userId); + sutProvider.GetDependency().GetByIdAsync(cipherId, userId).ReturnsNull(); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.GetAttachmentData(cipherId, attachmentId)); + } + + [Theory, BitAutoData] + public async Task GetAttachmentData_CipherFound_ReturnsAttachmentResponse( + Guid cipherId, string attachmentId, Guid userId, + SutProvider sutProvider) + { + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs((Guid?)userId); + + var cipherDetails = new CipherDetails { Id = cipherId, UserId = userId, Type = CipherType.Login, Data = "{}" }; + sutProvider.GetDependency().GetByIdAsync(cipherId, userId) + .Returns(Task.FromResult(cipherDetails)); + + var responseData = new AttachmentResponseData + { + Id = attachmentId, + Url = "https://example.com/download", + Data = new CipherAttachment.MetaData { FileName = "test.txt" }, + Cipher = cipherDetails, + }; + sutProvider.GetDependency() + .GetAttachmentDownloadDataAsync(cipherDetails, attachmentId) + .Returns(Task.FromResult(responseData)); + + var result = await sutProvider.Sut.GetAttachmentData(cipherId, attachmentId); + + Assert.NotNull(result); + Assert.Equal(attachmentId, result.Id); + } + + [Theory, BitAutoData] + public async Task DownloadAttachmentAsync_EmptyToken_ThrowsNotFoundException( + SutProvider sutProvider) + { + await Assert.ThrowsAsync( + () => sutProvider.Sut.DownloadAttachmentAsync(string.Empty)); + } + + [Theory, BitAutoData] + public async Task DownloadAttachmentAsync_InvalidToken_ThrowsNotFoundException( + SutProvider sutProvider) + { + sutProvider.GetDependency() + .ParseAttachmentDownloadToken(Arg.Any()) + .Throws(new NotFoundException()); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DownloadAttachmentAsync("invalid-token")); + } + + [Theory, BitAutoData] + public async Task DownloadAttachmentAsync_ValidToken_CipherNotFound_ThrowsNotFoundException( + Guid cipherId, string attachmentId, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .ParseAttachmentDownloadToken(Arg.Any()) + .Returns((cipherId, attachmentId)); + + sutProvider.GetDependency().GetByIdAsync(cipherId).ReturnsNull(); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DownloadAttachmentAsync("some-token")); + } + + [Theory, BitAutoData] + public async Task DownloadAttachmentAsync_ValidToken_NoAttachments_ThrowsNotFoundException( + Guid cipherId, string attachmentId, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .ParseAttachmentDownloadToken(Arg.Any()) + .Returns((cipherId, attachmentId)); + + var cipher = new Cipher { Id = cipherId, Attachments = null }; + sutProvider.GetDependency().GetByIdAsync(cipherId).Returns(cipher); + + await Assert.ThrowsAsync( + () => sutProvider.Sut.DownloadAttachmentAsync("some-token")); + } + + [Theory, BitAutoData] + public async Task DownloadAttachmentAsync_ValidToken_ReturnsFile( + Guid cipherId, string attachmentId, + SutProvider sutProvider) + { + var fileName = "secret-document.txt"; + var fileContent = new byte[] { 1, 2, 3 }; + var stream = new MemoryStream(fileContent); + + var metaData = new CipherAttachment.MetaData + { + AttachmentId = attachmentId, + FileName = fileName, + Size = fileContent.Length, + }; + + var cipher = new Cipher + { + Id = cipherId, + Attachments = JsonSerializer.Serialize( + new Dictionary { { attachmentId, metaData } }), + }; + + sutProvider.GetDependency() + .ParseAttachmentDownloadToken(Arg.Any()) + .Returns((cipherId, attachmentId)); + + sutProvider.GetDependency() + .GetByIdAsync(cipherId) + .Returns(cipher); + + sutProvider.GetDependency() + .GetAttachmentReadStreamAsync(cipher, Arg.Any()) + .Returns(stream); + + var result = await sutProvider.Sut.DownloadAttachmentAsync("valid-token"); + + var fileResult = Assert.IsType(result); + Assert.Equal("application/octet-stream", fileResult.ContentType); + Assert.Equal(fileName, fileResult.FileDownloadName); + Assert.Same(stream, fileResult.FileStream); + } } diff --git a/test/Core.Test/Services/LocalAttachmentStorageServiceTests.cs b/test/Core.Test/Services/LocalAttachmentStorageServiceTests.cs index 98681a19a03e..2a98b31a1fb7 100644 --- a/test/Core.Test/Services/LocalAttachmentStorageServiceTests.cs +++ b/test/Core.Test/Services/LocalAttachmentStorageServiceTests.cs @@ -1,5 +1,6 @@ using System.Text; using AutoFixture; +using Bit.Core.Exceptions; using Bit.Core.Settings; using Bit.Core.Test.AutoFixture.CipherAttachmentMetaData; using Bit.Core.Test.AutoFixture.CipherFixtures; @@ -8,6 +9,7 @@ using Bit.Core.Vault.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.DataProtection; using NSubstitute; using Xunit; @@ -233,7 +235,90 @@ private SutProvider GetSutProvider(TempDirectory var fixture = new Fixture().WithAutoNSubstitutions(); fixture.Freeze().Attachment.BaseDirectory.Returns(tempDirectory.Directory); fixture.Freeze().Attachment.BaseUrl.Returns(Guid.NewGuid().ToString()); + fixture.Freeze().BaseServiceUri.Api.Returns("https://api.example.com"); + fixture.Register(() => new EphemeralDataProtectionProvider()); return new SutProvider(fixture).Create(); } + + [Theory] + [InlineCustomAutoData(new[] { typeof(UserCipher), typeof(MetaData) })] + public async Task GetAttachmentDownloadUrlAsync_ReturnsSignedUrl(Cipher cipher, CipherAttachment.MetaData attachmentData) + { + using (var tempDirectory = new TempDirectory()) + { + var sutProvider = GetSutProvider(tempDirectory); + + var url = await sutProvider.Sut.GetAttachmentDownloadUrlAsync(cipher, attachmentData); + + Assert.Contains("ciphers/attachment/download", url); + Assert.Contains("token=", url); + Assert.StartsWith("https://api.example.com", url); + } + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(UserCipher), typeof(MetaData) })] + public async Task GetAttachmentDownloadUrlAsync_TokenCanBeParsedBack(Cipher cipher, CipherAttachment.MetaData attachmentData) + { + using (var tempDirectory = new TempDirectory()) + { + var sutProvider = GetSutProvider(tempDirectory); + + var url = await sutProvider.Sut.GetAttachmentDownloadUrlAsync(cipher, attachmentData); + + // Extract token from URL + var uri = new Uri(url); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + var token = query["token"]; + + var (parsedCipherId, parsedAttachmentId) = sutProvider.Sut.ParseAttachmentDownloadToken(token); + + Assert.Equal(cipher.Id, parsedCipherId); + Assert.Equal(attachmentData.AttachmentId, parsedAttachmentId); + } + } + + [Fact] + public void ParseAttachmentDownloadToken_InvalidToken_ThrowsNotFoundException() + { + using (var tempDirectory = new TempDirectory()) + { + var sutProvider = GetSutProvider(tempDirectory); + + Assert.Throws( + () => sutProvider.Sut.ParseAttachmentDownloadToken("invalid-token")); + } + } + + [Fact] + public void ParseAttachmentDownloadToken_InvalidFormat_ThrowsNotFoundException() + { + using (var tempDirectory = new TempDirectory()) + { + var sutProvider = GetSutProvider(tempDirectory); + + // Create a valid token but with invalid payload format (no pipe separator) + var provider = new EphemeralDataProtectionProvider(); + var protector = provider + .CreateProtector(LocalAttachmentStorageService.AttachmentDownloadProtectorPurpose) + .ToTimeLimitedDataProtector(); + var token = protector.Protect("invalid-data-without-pipe", TimeSpan.FromMinutes(1)); + + Assert.Throws( + () => sutProvider.Sut.ParseAttachmentDownloadToken(token)); + } + } + + [Fact] + public void ParseAttachmentDownloadToken_InvalidGuid_ThrowsNotFoundException() + { + using (var tempDirectory = new TempDirectory()) + { + var sutProvider = GetSutProvider(tempDirectory); + + Assert.Throws( + () => sutProvider.Sut.ParseAttachmentDownloadToken("not-a-real-token")); + } + } } diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 5fc92a9d3973..56430d7d7320 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -2575,4 +2575,54 @@ private async Task AssertNoActionsAsync(SutProvider sutProvider) await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushSyncCiphersAsync(default); } + + [Theory, BitAutoData] + public async Task GetAttachmentDownloadDataAsync_NullCipher_ThrowsNotFoundException( + string attachmentId, SutProvider sutProvider) + { + await Assert.ThrowsAsync( + () => sutProvider.Sut.GetAttachmentDownloadDataAsync(null, attachmentId)); + } + + [Theory, BitAutoData] + public async Task GetAttachmentDownloadDataAsync_AttachmentNotFound_ThrowsNotFoundException( + SutProvider sutProvider) + { + var cipher = new Cipher { Id = Guid.NewGuid(), Attachments = null }; + + await Assert.ThrowsAsync( + () => sutProvider.Sut.GetAttachmentDownloadDataAsync(cipher, "nonexistent")); + } + + [Theory, BitAutoData] + public async Task GetAttachmentDownloadDataAsync_ReturnsUrlFromStorageService( + SutProvider sutProvider) + { + var cipherId = Guid.NewGuid(); + var attachmentId = Guid.NewGuid().ToString(); + var expectedUrl = "https://example.com/download?token=abc"; + + var metaData = new CipherAttachment.MetaData + { + AttachmentId = attachmentId, + FileName = "test.txt", + Size = 100, + }; + + var cipher = new Cipher + { + Id = cipherId, + Attachments = System.Text.Json.JsonSerializer.Serialize( + new Dictionary { { attachmentId, metaData } }), + }; + + sutProvider.GetDependency() + .GetAttachmentDownloadUrlAsync(cipher, Arg.Any()) + .Returns(expectedUrl); + + var result = await sutProvider.Sut.GetAttachmentDownloadDataAsync(cipher, attachmentId); + + Assert.Equal(expectedUrl, result.Url); + Assert.Equal(attachmentId, result.Id); + } } From 5f19538948bb782a06207fdbafdc6de6a55de1b0 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 11 Mar 2026 09:15:41 +0100 Subject: [PATCH 60/85] [PM-30584] Add support for key-connector-migration setting key (#7136) * Add key-connector enrollment * Fix tests * Update src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Move validation to request model * Add tests * Fix build * Attempt to fix build * Attempt to fix remaining tests * Fix tests * Format --------- Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> --- .../AccountsKeyManagementController.cs | 27 +++++++- .../KeyConnectorEnrollmentRequestModel.cs | 20 ++++++ src/Core/Services/IUserService.cs | 2 +- .../Services/Implementations/UserService.cs | 7 +- .../AccountsKeyManagementControllerTests.cs | 54 +++++++++++++++ .../AccountsKeyManagementControllerTests.cs | 65 ++++++++++++++++-- ...KeyConnectorEnrollmentRequestModelTests.cs | 58 ++++++++++++++++ test/Core.Test/Services/UserServiceTests.cs | 67 +++++++++++++++++++ 8 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs create mode 100644 test/Api.Test/KeyManagement/Models/Request/KeyConnectorEnrollmentRequestModelTests.cs diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 4748e273523a..a857a6072120 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -160,7 +160,7 @@ public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyReque { // V1 account registration // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 - var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier); + var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key!, model.OrgIdentifier); if (result.Succeeded) { return; @@ -184,7 +184,30 @@ public async Task PostConvertToKeyConnectorAsync() throw new UnauthorizedAccessException(); } - var result = await _userService.ConvertToKeyConnectorAsync(user); + var result = await _userService.ConvertToKeyConnectorAsync(user, null); + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } + + [HttpPost("key-connector/enroll")] + public async Task PostEnrollToKeyConnectorAsync([FromBody] KeyConnectorEnrollmentRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var result = await _userService.ConvertToKeyConnectorAsync(user, model.KeyConnectorKeyWrappedUserKey); if (result.Succeeded) { return; diff --git a/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs b/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs new file mode 100644 index 000000000000..be8d883df982 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class KeyConnectorEnrollmentRequestModel : IValidatableObject +{ + [EncryptedString] + public required string KeyConnectorKeyWrappedUserKey { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(KeyConnectorKeyWrappedUserKey)) + { + yield return new ValidationResult( + "KeyConnectorKeyWrappedUserKey must be supplied when request body is provided.", + [nameof(KeyConnectorKeyWrappedUserKey)]); + } + } +} diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 3957b504ba89..c021fa2668e2 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -32,7 +32,7 @@ Task ChangeEmailAsync(User user, string masterPassword, string n // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 [Obsolete("Use ISetKeyConnectorKeyCommand instead. This method will be removed in a future version.")] Task SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier); - Task ConvertToKeyConnectorAsync(User user); + Task ConvertToKeyConnectorAsync(User user, string keyConnectorKeyWrappedUserKey); Task AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key); Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index d471daaa4caa..58f5cbb218fd 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -522,7 +522,7 @@ public async Task SetKeyConnectorKeyAsync(User user, string key, return IdentityResult.Success; } - public async Task ConvertToKeyConnectorAsync(User user) + public async Task ConvertToKeyConnectorAsync(User user, string keyConnectorKeyWrappedUserKey = null) { var identityResult = CheckCanUseKeyConnector(user); if (identityResult != null) @@ -534,6 +534,11 @@ public async Task ConvertToKeyConnectorAsync(User user) user.MasterPassword = null; user.UsesKeyConnector = true; + if (!string.IsNullOrWhiteSpace(keyConnectorKeyWrappedUserKey)) + { + user.Key = keyConnectorKeyWrappedUserKey; + } + await _userRepository.ReplaceAsync(user); await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index f07189f960fc..4dd5df514fe8 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -386,6 +386,60 @@ public async Task PostConvertToKeyConnectorAsync_Success() Assert.Equal(DateTime.UtcNow, user.AccountRevisionDate, TimeSpan.FromMinutes(1)); } + [Fact] + public async Task PostEnrollToKeyConnectorAsync_NotLoggedIn_Unauthorized() + { + var request = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = _mockEncryptedString + }; + + var response = await _client.PostAsJsonAsync("/accounts/key-connector/enroll", request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostEnrollToKeyConnectorAsync_KeyConnectorKeyWrappedUserKeyMissing_BadRequest() + { + var (ssoUserEmail, _) = await SetupKeyConnectorTestAsync(OrganizationUserStatusType.Accepted); + + var request = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = " " + }; + + var response = await _client.PostAsJsonAsync("/accounts/key-connector/enroll", request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var user = await _userRepository.GetByEmailAsync(ssoUserEmail); + Assert.NotNull(user); + Assert.False(user.UsesKeyConnector); + } + + [Fact] + public async Task PostEnrollToKeyConnectorAsync_Success() + { + var (ssoUserEmail, _) = await SetupKeyConnectorTestAsync(OrganizationUserStatusType.Accepted); + + var request = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = _mockEncryptedString + }; + + var response = await _client.PostAsJsonAsync("/accounts/key-connector/enroll", request); + response.EnsureSuccessStatusCode(); + + var user = await _userRepository.GetByEmailAsync(ssoUserEmail); + Assert.NotNull(user); + Assert.Null(user.MasterPassword); + Assert.True(user.UsesKeyConnector); + Assert.Equal(request.KeyConnectorKeyWrappedUserKey, user.Key); + Assert.Equal(DateTime.UtcNow, user.RevisionDate, TimeSpan.FromMinutes(1)); + Assert.Equal(DateTime.UtcNow, user.AccountRevisionDate, TimeSpan.FromMinutes(1)); + } + [Theory] [BitAutoData] public async Task RotateV2UserAccountKeysAsync_Success(RotateUserAccountKeysAndDataRequestModel request) diff --git a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 17639951e9e7..359b6dc53016 100644 --- a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -490,7 +490,7 @@ public async Task PostConvertToKeyConnectorAsync_UserNull_Throws( await Assert.ThrowsAsync(() => sutProvider.Sut.PostConvertToKeyConnectorAsync()); await sutProvider.GetDependency().ReceivedWithAnyArgs(0) - .ConvertToKeyConnectorAsync(Arg.Any()); + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()); } [Theory] @@ -502,7 +502,7 @@ public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorFails_Thro sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) .Returns(expectedUser); sutProvider.GetDependency() - .ConvertToKeyConnectorAsync(Arg.Any()) + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()) .Returns(IdentityResult.Failed(new IdentityError { Description = "convert to key connector error" })); var badRequestException = @@ -511,7 +511,7 @@ public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorFails_Thro Assert.Equal(1, badRequestException.ModelState!.ErrorCount); Assert.Equal("convert to key connector error", badRequestException.ModelState.Root.Errors[0].ErrorMessage); await sutProvider.GetDependency().Received(1) - .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Any()); } [Theory] @@ -523,13 +523,68 @@ public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorSucceeds_O sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) .Returns(expectedUser); sutProvider.GetDependency() - .ConvertToKeyConnectorAsync(Arg.Any()) + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()) .Returns(IdentityResult.Success); await sutProvider.Sut.PostConvertToKeyConnectorAsync(); await sutProvider.GetDependency().Received(1) - .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostEnrollToKeyConnectorAsync_UserNull_Throws( + SutProvider sutProvider, + KeyConnectorEnrollmentRequestModel data) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PostEnrollToKeyConnectorAsync(data)); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostEnrollToKeyConnectorAsync_ConvertToKeyConnectorFails_ThrowsBadRequestWithErrorResponse( + SutProvider sutProvider, + User expectedUser, + KeyConnectorEnrollmentRequestModel data) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Failed(new IdentityError { Description = "convert to key connector error" })); + + var badRequestException = + await Assert.ThrowsAsync(() => sutProvider.Sut.PostEnrollToKeyConnectorAsync(data)); + + Assert.Equal(1, badRequestException.ModelState!.ErrorCount); + Assert.Equal("convert to key connector error", badRequestException.ModelState.Root.Errors[0].ErrorMessage); + await sutProvider.GetDependency().Received(1) + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Is(data.KeyConnectorKeyWrappedUserKey)); + } + + [Theory] + [BitAutoData] + public async Task PostEnrollToKeyConnectorAsync_ConvertToKeyConnectorSucceeds_OkResponse( + SutProvider sutProvider, + User expectedUser, + KeyConnectorEnrollmentRequestModel data) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + + await sutProvider.Sut.PostEnrollToKeyConnectorAsync(data); + + await sutProvider.GetDependency().Received(1) + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Is(data.KeyConnectorKeyWrappedUserKey)); } [Theory] diff --git a/test/Api.Test/KeyManagement/Models/Request/KeyConnectorEnrollmentRequestModelTests.cs b/test/Api.Test/KeyManagement/Models/Request/KeyConnectorEnrollmentRequestModelTests.cs new file mode 100644 index 000000000000..9d7e47e3bd69 --- /dev/null +++ b/test/Api.Test/KeyManagement/Models/Request/KeyConnectorEnrollmentRequestModelTests.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Models.Requests; +using Xunit; + +namespace Bit.Api.Test.KeyManagement.Models.Request; + +public class KeyConnectorEnrollmentRequestModelTests +{ + private const string _wrappedUserKey = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + + [Fact] + public void Validate_KeyConnectorKeyWrappedUserKeyNull_Invalid() + { + var model = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = null! + }; + + var results = Validate(model); + + Assert.Contains(results, + r => r.ErrorMessage == "KeyConnectorKeyWrappedUserKey must be supplied when request body is provided."); + } + + [Fact] + public void Validate_KeyConnectorKeyWrappedUserKeyWhitespace_Invalid() + { + var model = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = " " + }; + + var results = Validate(model); + + Assert.Contains(results, + r => r.ErrorMessage == "KeyConnectorKeyWrappedUserKey is not a valid encrypted string."); + } + + [Fact] + public void Validate_KeyConnectorKeyWrappedUserKeyValid_Success() + { + var model = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = _wrappedUserKey + }; + + var results = Validate(model); + + Assert.Empty(results); + } + + private static List Validate(KeyConnectorEnrollmentRequestModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } +} diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 24c637459ca0..8c01f21d3893 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -15,6 +15,7 @@ using Bit.Core.Billing.Premium.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -585,6 +586,72 @@ public async Task RecoverTwoFactorAsync_IncorrectCode_ReturnsFalse( Assert.NotNull(user.TwoFactorProviders); } + [Theory] + [BitAutoData("wrapped-user-key")] + [BitAutoData("2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=")] + public async Task ConvertToKeyConnectorAsync_WrappedUserKeyProvided_SetsWrappedUserKey( + string wrappedUserKey, + SutProvider sutProvider, + User user) + { + // Arrange + user.UsesKeyConnector = false; + user.MasterPassword = "master-password"; + user.Key = "old-key"; + sutProvider.GetDependency().Organizations = []; + + // Act + var result = await sutProvider.Sut.ConvertToKeyConnectorAsync(user, wrappedUserKey); + + // Assert + Assert.True(result.Succeeded); + Assert.True(user.UsesKeyConnector); + Assert.Null(user.MasterPassword); + Assert.Equal(wrappedUserKey, user.Key); + Assert.Equal(user.RevisionDate, user.AccountRevisionDate); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(Arg.Is(u => + u == user && + u.Key == wrappedUserKey && + u.MasterPassword == null && + u.UsesKeyConnector)); + await sutProvider.GetDependency().Received(1) + .LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); + } + + [Theory, BitAutoData] + public async Task ConvertToKeyConnectorAsync_WrappedUserKeyNull_DoesNotOverwriteExistingKey( + SutProvider sutProvider, + User user) + { + // Arrange + const string existingUserKey = "existing-user-key"; + user.UsesKeyConnector = false; + user.MasterPassword = "master-password"; + user.Key = existingUserKey; + sutProvider.GetDependency().Organizations = []; + + // Act + var result = await sutProvider.Sut.ConvertToKeyConnectorAsync(user, null); + + // Assert + Assert.True(result.Succeeded); + Assert.True(user.UsesKeyConnector); + Assert.Null(user.MasterPassword); + Assert.Equal(existingUserKey, user.Key); + Assert.Equal(user.RevisionDate, user.AccountRevisionDate); + + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(Arg.Is(u => + u == user && + u.Key == existingUserKey && + u.MasterPassword == null && + u.UsesKeyConnector)); + + await sutProvider.GetDependency().Received(1) + .LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); + } + private static void SetupUserAndDevice(User user, bool shouldHavePassword) { From d8592f4a9a402d5d07e84892c3dce1bc693e1d75 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Wed, 11 Mar 2026 11:33:54 -0400 Subject: [PATCH 61/85] [PM-33040] Add new interface methods to IApplicationCacheService (#7187) --- src/Core/Services/IApplicationCacheService.cs | 20 +++ .../FeatureRoutedCacheService.cs | 22 ++++ .../InMemoryApplicationCacheService.cs | 24 ++++ .../FeatureRoutedCacheServiceTests.cs | 117 ++++++++++++++++++ 4 files changed, 183 insertions(+) diff --git a/src/Core/Services/IApplicationCacheService.cs b/src/Core/Services/IApplicationCacheService.cs index 23a58b48f307..bb06b8135481 100644 --- a/src/Core/Services/IApplicationCacheService.cs +++ b/src/Core/Services/IApplicationCacheService.cs @@ -11,9 +11,29 @@ public interface IApplicationCacheService Task> GetOrganizationAbilitiesAsync(); #nullable enable Task GetOrganizationAbilityAsync(Guid orgId); + /// + /// Gets the cached for the specified provider. + /// + /// The ID of the provider. + /// The if found; otherwise, null. + Task GetProviderAbilityAsync(Guid providerId); #nullable disable [Obsolete("We are transitioning to a new cache pattern. Please consult the Admin Console team before using.", false)] Task> GetProviderAbilitiesAsync(); + /// + /// Gets cached entries for the specified providers. + /// Provider IDs not found in the cache are silently excluded from the result. + /// + /// The IDs of the providers to look up. + /// A dictionary mapping each found provider ID to its . + Task> GetProviderAbilitiesAsync(IEnumerable providerIds); + /// + /// Gets cached entries for the specified organizations. + /// Organization IDs not found in the cache are silently excluded from the result. + /// + /// The IDs of the organizations to look up. + /// A dictionary mapping each found organization ID to its . + Task> GetOrganizationAbilitiesAsync(IEnumerable orgIds); Task UpsertOrganizationAbilityAsync(Organization organization); Task UpsertProviderAbilityAsync(Provider provider); Task DeleteOrganizationAbilityAsync(Guid organizationId); diff --git a/src/Core/Services/Implementations/FeatureRoutedCacheService.cs b/src/Core/Services/Implementations/FeatureRoutedCacheService.cs index abd57a4f3afc..6769b1f0c184 100644 --- a/src/Core/Services/Implementations/FeatureRoutedCacheService.cs +++ b/src/Core/Services/Implementations/FeatureRoutedCacheService.cs @@ -19,6 +19,28 @@ public Task> GetOrganizationAbilitiesAsyn public Task> GetProviderAbilitiesAsync() => inMemoryApplicationCacheService.GetProviderAbilitiesAsync(); + public async Task GetProviderAbilityAsync(Guid providerId) + { + (await GetProviderAbilitiesAsync([providerId])).TryGetValue(providerId, out var providerAbility); + return providerAbility; + } + + public async Task> GetProviderAbilitiesAsync(IEnumerable providerIds) + { + var allProviderAbilities = await inMemoryApplicationCacheService.GetProviderAbilitiesAsync(); + return providerIds + .Where(allProviderAbilities.ContainsKey) + .ToDictionary(id => id, id => allProviderAbilities[id]); + } + + public async Task> GetOrganizationAbilitiesAsync(IEnumerable orgIds) + { + var allOrganizationAbilities = await inMemoryApplicationCacheService.GetOrganizationAbilitiesAsync(); + return orgIds + .Where(allOrganizationAbilities.ContainsKey) + .ToDictionary(id => id, id => allOrganizationAbilities[id]); + } + public Task UpsertOrganizationAbilityAsync(Organization organization) => inMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization); diff --git a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs index 4062162701cd..0709ad310606 100644 --- a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs +++ b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs @@ -49,6 +49,30 @@ public virtual async Task> GetProviderAbiliti return _providerAbilities; } +#nullable enable + public async Task GetProviderAbilityAsync(Guid providerId) + { + (await GetProviderAbilitiesAsync()).TryGetValue(providerId, out var providerAbility); + return providerAbility; + } +#nullable disable + + public async Task> GetProviderAbilitiesAsync(IEnumerable providerIds) + { + var allProviderAbilities = await GetProviderAbilitiesAsync(); + return providerIds + .Where(allProviderAbilities.ContainsKey) + .ToDictionary(id => id, id => allProviderAbilities[id]); + } + + public async Task> GetOrganizationAbilitiesAsync(IEnumerable orgIds) + { + var allOrganizationAbilities = await GetOrganizationAbilitiesAsync(); + return orgIds + .Where(allOrganizationAbilities.ContainsKey) + .ToDictionary(id => id, id => allOrganizationAbilities[id]); + } + public virtual async Task UpsertProviderAbilityAsync(Provider provider) { await InitProviderAbilitiesAsync(); diff --git a/test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs b/test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs index 01ca333ad722..ede442d511a8 100644 --- a/test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs +++ b/test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs @@ -79,6 +79,123 @@ await sutProvider.GetDependency() .GetProviderAbilitiesAsync(); } + [Theory, BitAutoData] + public async Task GetProviderAbilityAsync_WhenProviderExists_ReturnsAbility( + SutProvider sutProvider, + ProviderAbility providerAbility) + { + // Arrange + var allAbilities = new Dictionary { [providerAbility.Id] = providerAbility }; + sutProvider.GetDependency() + .GetProviderAbilitiesAsync() + .Returns(allAbilities); + + // Act + var result = await sutProvider.Sut.GetProviderAbilityAsync(providerAbility.Id); + + // Assert + Assert.Equal(providerAbility, result); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilityAsync_WhenProviderDoesNotExist_ReturnsNull( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + sutProvider.GetDependency() + .GetProviderAbilitiesAsync() + .Returns(new Dictionary()); + + // Act + var result = await sutProvider.Sut.GetProviderAbilityAsync(providerId); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_ReturnsOnlyMatchingAbilities( + SutProvider sutProvider, + ProviderAbility matchedAbility, + ProviderAbility unmatchedAbility) + { + // Arrange + var allAbilities = new Dictionary + { + [matchedAbility.Id] = matchedAbility, + [unmatchedAbility.Id] = unmatchedAbility + }; + sutProvider.GetDependency() + .GetProviderAbilitiesAsync() + .Returns(allAbilities); + + // Act + var result = await sutProvider.Sut.GetProviderAbilitiesAsync([matchedAbility.Id]); + + // Assert + Assert.Single(result); + Assert.Equal(matchedAbility, result[matchedAbility.Id]); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_WhenNoIdsMatched_ReturnsEmptyDictionary( + SutProvider sutProvider, + Guid missingProviderId) + { + // Arrange + sutProvider.GetDependency() + .GetProviderAbilitiesAsync() + .Returns(new Dictionary()); + + // Act + var result = await sutProvider.Sut.GetProviderAbilitiesAsync([missingProviderId]); + + // Assert + Assert.Empty(result); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_ReturnsOnlyMatchingAbilities( + SutProvider sutProvider, + OrganizationAbility matchedAbility, + OrganizationAbility unmatchedAbility) + { + // Arrange + var allAbilities = new Dictionary + { + [matchedAbility.Id] = matchedAbility, + [unmatchedAbility.Id] = unmatchedAbility + }; + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(allAbilities); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync([matchedAbility.Id]); + + // Assert + Assert.Single(result); + Assert.Equal(matchedAbility, result[matchedAbility.Id]); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_WhenNoIdsMatched_ReturnsEmptyDictionary( + SutProvider sutProvider, + Guid missingOrgId) + { + // Arrange + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(new Dictionary()); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync([missingOrgId]); + + // Assert + Assert.Empty(result); + } + [Theory, BitAutoData] public async Task UpsertOrganizationAbilityAsync_CallsInMemoryService( SutProvider sutProvider, From 7d5ff28afad022b96d7bbb3c72ab401eb41a3299 Mon Sep 17 00:00:00 2001 From: Jared Date: Wed, 11 Mar 2026 11:34:36 -0400 Subject: [PATCH 62/85] Refactor email confirmation logic to remove legacy mail service usage and streamline organization confirmation process (#7192) --- ...maticallyConfirmOrganizationUserCommand.cs | 14 +---- .../ConfirmOrganizationUserCommand.cs | 15 +---- .../InitPendingOrganizationCommand.cs | 12 +--- src/Core/Constants.cs | 1 - .../AutomaticallyConfirmUsersCommandTests.cs | 59 ++----------------- .../ConfirmOrganizationUserCommandTests.cs | 58 ++---------------- .../InitPendingOrganizationCommandTests.cs | 4 -- 7 files changed, 15 insertions(+), 148 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs index adc6ef59921b..ab8aaface0fd 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser/AutomaticallyConfirmOrganizationUserCommand.cs @@ -18,14 +18,12 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi IOrganizationRepository organizationRepository, IAutomaticallyConfirmOrganizationUsersValidator validator, IEventService eventService, - IMailService mailService, IUserRepository userRepository, IPushRegistrationService pushRegistrationService, IDeviceRepository deviceRepository, IPushNotificationService pushNotificationService, IPolicyRequirementQuery policyRequirementQuery, ICollectionRepository collectionRepository, - IFeatureService featureService, ISendOrganizationConfirmationCommand sendOrganizationConfirmationCommand, TimeProvider timeProvider, ILogger logger) : IAutomaticallyConfirmOrganizationUserCommand @@ -177,21 +175,13 @@ private async Task Retrie } /// - /// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service, - /// depending on the feature flag. + /// Sends the organization confirmed email using the new mailer pattern. /// /// The organization the user was confirmed to. /// The email address of the confirmed user. /// Whether the user has access to Secrets Manager. internal async Task SendOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager) { - if (featureService.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)) - { - await sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager); - } - else - { - await mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), userEmail, accessSecretsManager); - } + await sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 53c1eefc0c8b..1ffafc1c929e 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -28,7 +28,6 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IUserRepository _userRepository; private readonly IEventService _eventService; - private readonly IMailService _mailService; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IPushNotificationService _pushNotificationService; private readonly IPushRegistrationService _pushRegistrationService; @@ -46,7 +45,6 @@ public ConfirmOrganizationUserCommand( IOrganizationUserRepository organizationUserRepository, IUserRepository userRepository, IEventService eventService, - IMailService mailService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IPushNotificationService pushNotificationService, IPushRegistrationService pushRegistrationService, @@ -63,7 +61,6 @@ public ConfirmOrganizationUserCommand( _organizationUserRepository = organizationUserRepository; _userRepository = userRepository; _eventService = eventService; - _mailService = mailService; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _pushNotificationService = pushNotificationService; _pushRegistrationService = pushRegistrationService; @@ -327,21 +324,13 @@ private async Task CreateManyDefaultCollectionsAsync(Organization organization, } /// - /// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service, - /// depending on the feature flag. + /// Sends the organization confirmed email using the new mailer pattern. /// /// The organization the user was confirmed to. /// The email address of the confirmed user. /// Whether the user has access to Secrets Manager. internal async Task SendOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager) { - if (_featureService.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)) - { - await _sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager); - } - else - { - await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), userEmail, accessSecretsManager); - } + await _sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs index fc35940cca11..621e113a89f9 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommand.cs @@ -28,7 +28,6 @@ public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand private readonly IFeatureService _featureService; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IEventService _eventService; - private readonly IMailService _mailService; private readonly IUserRepository _userRepository; private readonly IPushNotificationService _pushNotificationService; private readonly IPushRegistrationService _pushRegistrationService; @@ -46,7 +45,6 @@ public InitPendingOrganizationCommand( IFeatureService featureService, IPolicyRequirementQuery policyRequirementQuery, IEventService eventService, - IMailService mailService, IUserRepository userRepository, IPushNotificationService pushNotificationService, IPushRegistrationService pushRegistrationService, @@ -63,7 +61,6 @@ public InitPendingOrganizationCommand( _featureService = featureService; _policyRequirementQuery = policyRequirementQuery; _eventService = eventService; - _mailService = mailService; _userRepository = userRepository; _pushNotificationService = pushNotificationService; _pushRegistrationService = pushRegistrationService; @@ -268,14 +265,7 @@ private async Task SendNotificationsAsync(Organization org, OrganizationUser org { await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - if (_featureService.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)) - { - await _sendOrganizationConfirmationCommand.SendConfirmationAsync(org, user.Email, orgUser.AccessSecretsManager); - } - else - { - await _mailService.SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, orgUser.AccessSecretsManager); - } + await _sendOrganizationConfirmationCommand.SendConfirmationAsync(org, user.Email, orgUser.AccessSecretsManager); await _pushNotificationService.PushSyncOrgKeysAsync(user.Id); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index a86370d97c21..c51a1b2ca5da 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -159,7 +159,6 @@ public static class FeatureFlagKeys public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; public const string PM2035PasskeyUnlock = "pm-2035-passkey-unlock"; public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email"; - public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template"; public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow"; public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin"; public const string SafariAccountSwitching = "pm-5594-safari-account-switching"; diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs index e2e4c9d41989..629f2c1b02c6 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUsers/AutomaticallyConfirmUsersCommandTests.cs @@ -389,8 +389,8 @@ public async Task AutomaticallyConfirmOrganizationUserAsync_WhenSendEmailFails_L .Returns(true); var emailException = new Exception("Email sending failed"); - sutProvider.GetDependency() - .SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, organizationUser.AccessSecretsManager) + sutProvider.GetDependency() + .SendConfirmationAsync(organization, user.Email, organizationUser.AccessSecretsManager) .ThrowsAsync(emailException); // Act @@ -702,10 +702,10 @@ await sutProvider.GetDependency() EventType.OrganizationUser_AutomaticallyConfirmed, Arg.Any()); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .SendOrganizationConfirmedEmailAsync( - organization.DisplayName(), + .SendConfirmationAsync( + organization, user.Email, organizationUser.AccessSecretsManager); @@ -720,53 +720,4 @@ await sutProvider.GetDependency() organization.Id.ToString()); } - [Theory] - [BitAutoData] - public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOn_CallsSendOrganizationConfirmationCommand( - Organization organization, - string userEmail, - SutProvider sutProvider) - { - // Arrange - const bool accessSecretsManager = true; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail) - .Returns(true); - - // Act - await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(organization, userEmail, accessSecretsManager); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .SendConfirmationAsync(organization, userEmail, accessSecretsManager); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .SendOrganizationConfirmedEmailAsync(default, default, default); - } - - [Theory] - [BitAutoData] - public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOff_UsesLegacyMailService( - Organization organization, - string userEmail, - SutProvider sutProvider) - { - // Arrange - const bool accessSecretsManager = false; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail) - .Returns(false); - - // Act - await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(organization, userEmail, accessSecretsManager); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .SendOrganizationConfirmedEmailAsync(organization.DisplayName(), userEmail, accessSecretsManager); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .SendConfirmationAsync(default, default, default); - } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs index aa6e6b0c8e08..08c674bd37bd 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -148,7 +148,7 @@ public async Task ConfirmUserAsync_ToNonFree_WithExistingFreeAdminOrOwner_Succee await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email); + await sutProvider.GetDependency().Received(1).SendConfirmationAsync(org, user.Email, orgUser.AccessSecretsManager); await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1)); await sutProvider.GetDependency() .Received(1) @@ -409,7 +409,7 @@ public async Task ConfirmUserAsync_WithPolicyRequirementsEnabled_AndTwoFactorNot await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, "key", confirmingUser.Id); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, orgUser.AccessSecretsManager); + await sutProvider.GetDependency().Received(1).SendConfirmationAsync(org, user.Email, orgUser.AccessSecretsManager); await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1)); } @@ -452,7 +452,7 @@ public async Task ConfirmUserAsync_WithPolicyRequirementsEnabled_AndTwoFactorEna await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, "key", confirmingUser.Id); await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await sutProvider.GetDependency().Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, orgUser.AccessSecretsManager); + await sutProvider.GetDependency().Received(1).SendConfirmationAsync(org, user.Email, orgUser.AccessSecretsManager); await organizationUserRepository.Received(1).ReplaceManyAsync(Arg.Is>(users => users.Contains(orgUser) && users.Count == 1)); } @@ -725,8 +725,8 @@ public async Task ConfirmUserAsync_WithAutoConfirmNotApplicable_Succeeds( // Assert await sutProvider.GetDependency() .Received(1).LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed); - await sutProvider.GetDependency() - .Received(1).SendOrganizationConfirmedEmailAsync(org.DisplayName(), user.Email, orgUser.AccessSecretsManager); + await sutProvider.GetDependency() + .Received(1).SendConfirmationAsync(org, user.Email, orgUser.AccessSecretsManager); } [Theory, BitAutoData] @@ -934,54 +934,6 @@ public async Task ConfirmUsersAsync_WithAutoConfirmEnabled_MixedResults( Assert.Equal(new OtherOrganizationDoesNotAllowOtherMembership().Message, result[2].Item2); } - [Theory, BitAutoData] - public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOn_CallsSendOrganizationConfirmationCommand( - Organization org, - string userEmail, - SutProvider sutProvider) - { - // Arrange - const bool accessSecretsManager = true; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail) - .Returns(true); - - // Act - await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(org, userEmail, accessSecretsManager); - - // Assert - verify new mailer is called, not legacy mail service - await sutProvider.GetDependency() - .Received(1) - .SendConfirmationAsync(org, userEmail, accessSecretsManager); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .SendOrganizationConfirmedEmailAsync(default, default, default); - } - - [Theory, BitAutoData] - public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOff_UsesLegacyMailService( - Organization org, - string userEmail, - SutProvider sutProvider) - { - // Arrange - const bool accessSecretsManager = false; - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail) - .Returns(false); - - // Act - await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(org, userEmail, accessSecretsManager); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .SendOrganizationConfirmedEmailAsync(org.DisplayName(), userEmail, accessSecretsManager); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .SendConfirmationAsync(default, default, default); - } - [Theory, BitAutoData] public async Task ConfirmUserAsync_UseMyItemsDisabled_DoesNotCreateDefaultCollection( Organization organization, OrganizationUser confirmingUser, diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs index 5869e74d150a..2338b9c535de 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Organizations/InitPendingOrganizationCommandTests.cs @@ -373,9 +373,5 @@ private static void SetupSuccessfulValidation( sutProvider.GetDependency() .GetManyByUserIdAsync(request.User.Id) .Returns(new List()); - - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail) - .Returns(true); } } From 01059fd8f5d2914bb814073aadf17a5664644193 Mon Sep 17 00:00:00 2001 From: sven-bitwarden Date: Wed, 11 Mar 2026 11:13:01 -0500 Subject: [PATCH 63/85] Fixes swagger authentication (#7197) --- src/Api/Startup.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 44398cfc726c..c28c0b0b5089 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -336,9 +336,12 @@ public void Configure( [ new OpenApiSecurityRequirement { - [new OpenApiSecuritySchemeReference("oauth2-client-credentials")] = [ApiScopes.ApiOrganization] + [new OpenApiSecuritySchemeReference("oauth2-client-credentials", swaggerDoc)] = [ApiScopes.ApiOrganization] }, ]; + + swaggerDoc.Workspace = new OpenApiWorkspace(); + swaggerDoc.RegisterComponents(); }); }); From 18956c93dd8705d032e8acfa5d9166d7734611eb Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Wed, 11 Mar 2026 17:16:41 +0100 Subject: [PATCH 64/85] Add 9 scale presets and consolidated seeder docs (#7193) * Add 9 scale presets and consolidated seeder docs --- util/Seeder/CLAUDE.md | 8 +- util/Seeder/Data/README.md | 145 +------ util/Seeder/README.md | 85 +--- util/Seeder/Seeds/README.md | 75 +--- util/Seeder/Seeds/docs/fixtures.md | 49 +++ util/Seeder/Seeds/docs/presets.md | 83 ++++ util/Seeder/Seeds/docs/verification.md | 371 ++++++++++++++++++ .../scale/lg-balanced-wayne-enterprises.json | 23 ++ .../scale/lg-highperm-tyrell-corp.json | 22 ++ .../scale/md-balanced-sterling-cooper.json | 22 ++ .../md-highcollection-umbrella-corp.json | 22 ++ .../scale/sm-balanced-planet-express.json | 21 + .../scale/sm-highperm-bluth-company.json | 21 + .../presets/scale/xl-broad-initech.json | 21 + .../scale/xl-highperm-weyland-yutani.json | 23 ++ .../presets/scale/xs-central-perk.json | 21 + 16 files changed, 726 insertions(+), 286 deletions(-) create mode 100644 util/Seeder/Seeds/docs/fixtures.md create mode 100644 util/Seeder/Seeds/docs/presets.md create mode 100644 util/Seeder/Seeds/docs/verification.md create mode 100644 util/Seeder/Seeds/fixtures/presets/scale/lg-balanced-wayne-enterprises.json create mode 100644 util/Seeder/Seeds/fixtures/presets/scale/lg-highperm-tyrell-corp.json create mode 100644 util/Seeder/Seeds/fixtures/presets/scale/md-balanced-sterling-cooper.json create mode 100644 util/Seeder/Seeds/fixtures/presets/scale/md-highcollection-umbrella-corp.json create mode 100644 util/Seeder/Seeds/fixtures/presets/scale/sm-balanced-planet-express.json create mode 100644 util/Seeder/Seeds/fixtures/presets/scale/sm-highperm-bluth-company.json create mode 100644 util/Seeder/Seeds/fixtures/presets/scale/xl-broad-initech.json create mode 100644 util/Seeder/Seeds/fixtures/presets/scale/xl-highperm-weyland-yutani.json create mode 100644 util/Seeder/Seeds/fixtures/presets/scale/xs-central-perk.json diff --git a/util/Seeder/CLAUDE.md b/util/Seeder/CLAUDE.md index f8006eaca638..7fa1ba7d5336 100644 --- a/util/Seeder/CLAUDE.md +++ b/util/Seeder/CLAUDE.md @@ -66,7 +66,9 @@ Steps accept an optional `DensityProfile` that controls relationship patterns be **Preset JSON**: Add an optional `"density": { ... }` block. See `Seeds/schemas/preset.schema.json` for the full schema. -**Validation presets**: `Seeds/fixtures/presets/validation/` contains presets that verify density algorithms produce correct distributions. See the README in that folder for queries and expected results. +**Presets**: Organized into `features/`, `qa/`, `scale/`, `validation/` folders under `Seeds/fixtures/presets/`. See `Seeds/docs/presets.md` for the full catalog. + +**Verification**: SQL queries for validating density algorithms are in `Seeds/docs/verification.md`. ## The Recipe Contract @@ -93,7 +95,7 @@ The Seeder uses the Rust SDK via FFI because it must behave like a real Bitwarde ## Data Flow ``` -CipherViewDto → Rust SDK encrypt_cipher → EncryptedCipherDto → TransformToServer → Server Cipher Entity +CipherViewDto → Rust SDK encrypt_cipher → EncryptedCipherDto → EncryptedCipherDtoExtensions → Server Cipher Entity ``` Shared logic: `CipherEncryption.cs`, `EncryptedCipherDtoExtensions.cs` @@ -112,7 +114,7 @@ Before modifying SDK integration, run `RustSdkCipherTests` to validate roundtrip Same domain = same seed = reproducible data: ```csharp -_seed = options.Seed ?? StableHash.ToInt32(options.Domain); +var seed = options.Seed ?? DeriveStableSeed(options.Domain); ``` ## Security Reminders diff --git a/util/Seeder/Data/README.md b/util/Seeder/Data/README.md index 2c9751b98210..8b9a64da0be7 100644 --- a/util/Seeder/Data/README.md +++ b/util/Seeder/Data/README.md @@ -11,166 +11,27 @@ Foundation layer for all cipher generation—data and patterns that future ciphe - **Deterministic.** Seeded randomness means same org ID → same test data → reproducible debugging. - **Filterable.** `Companies.Filter(type, region, category)` for targeted data selection. ---- - ## Generators Seeded, deterministic data generation for cipher content. Orchestrated by `GeneratorContext` which lazy-initializes on first access. -| Generator | Output | Method | -|-----------|--------|--------| -| `CipherUsernameGenerator` | Emails, handles | `GenerateByIndex(index, totalHint, domain)` | -| `CardDataGenerator` | Card numbers, names | `GenerateByIndex(index)` | -| `IdentityDataGenerator` | Full identity profiles | `GenerateByIndex(index)` | -| `FolderNameGenerator` | Folder names | `GetFolderName(index)` | -| `SecureNoteDataGenerator` | Note title + content | `GenerateByIndex(index)` | -| `SshKeyDataGenerator` | RSA key pairs | `GenerateByIndex(index)` | - **Adding a generator:** See `GeneratorContext.cs` remarks for the 3-step pattern. ---- - ## Distributions Percentage-based deterministic selection via `Distribution.Select(index, total)`. -| Distribution | Values | Usage | -|--------------|--------|-------| -| `PasswordDistributions.Realistic` | 25% VeryWeak → 5% VeryStrong | Password strength mix | -| `UsernameDistributions.Realistic` | 45% corporate, 30% personal, etc. | Username category mix | -| `CipherTypeDistributions.Realistic` | 70% Login, 15% Card, etc. | Cipher type mix | -| `UserStatusDistributions.Realistic` | 85% Confirmed, 5% each other | Org user status mix | -| `FolderCountDistributions.Realistic` | 35% zero, 35% 1-3, etc. | Folders per user | - ---- - ## Current Capabilities ### Login Ciphers - 50 real companies across 3 regions with metadata (category, type, domain) -- 200 first names + 200 last names (US, European) -- 6 username patterns (corporate email conventions) -- 3 password strength levels (95 total passwords) +- Locale-aware name generation via Bogus (Faker) library with 1500-entry pools +- 8 username patterns (corporate email, personal, social, employee ID, etc.) +- 5 password strength levels (138 total passwords) ### Organizational Structures - Traditional (departments + sub-units) - Spotify Model (tribes, squads, chapters, guilds) - Modern/AI-First (feature teams, platform teams, pods) - ---- - -## Roadmap - -### Phase 1: Additional Cipher Types - -| Cipher Type | Data Needed | Status | -| ----------- | ---------------------------------------------------- | ----------- | -| Login | Companies, Names, Passwords, Patterns | ✅ Complete | -| Card | Card networks, bank names, realistic numbers | ✅ Complete | -| Identity | Full identity profiles (name, address, SSN patterns) | ✅ Complete | -| SecureNote | Note templates, categories, content generators | ✅ Complete | -| SSH Key | RSA key pairs, fingerprints | ✅ Complete | - -### Phase 2: Spec-Driven Generation - -Import a specification file and generate a complete vault to match: - -```yaml -# Example: organization-spec.yaml -organization: - name: "Acme Corp" - users: 500 - -collections: - structure: spotify # Use Spotify org model - -ciphers: - logins: - count: 2000 - companies: - type: enterprise - region: north_america - passwords: mixed # Realistic distribution - username_pattern: first_dot_last - - cards: - count: 100 - networks: [visa, mastercard, amex] - - identities: - count: 200 - regions: [us, europe] - - secure_notes: - count: 300 - categories: [api_keys, licenses, documentation] -``` - -**Spec Engine Components (Future)** - -- `SpecParser` - YAML/JSON spec file parsing -- `SpecValidator` - Schema validation -- `SpecExecutor` - Orchestrates generation from spec -- `ProgressReporter` - Real-time generation progress - -### Phase 3: Data Enhancements - -| Enhancement | Description | -| ----------------------- | ---------------------------------------------------- | -| **Additional Regions** | LatinAmerica, MiddleEast, Africa companies and names | -| **Industry Verticals** | Healthcare, Finance, Government-specific companies | -| **Localized Passwords** | Region-specific common passwords | -| **Custom Fields** | Field templates per cipher type | -| **TOTP Seeds** | Realistic 2FA seed generation | -| **Attachments** | File attachment simulation | -| **Password History** | Historical password entries | - -### Phase 4: Advanced Features - -- **Relationship Graphs** - Ciphers that reference each other (SSO relationships) -- **Temporal Data** - Realistic created/modified timestamps over time -- **Access Patterns** - Simulate realistic collection/group membership distributions -- **Breach Simulation** - Mark specific passwords as "exposed" for security testing - ---- - -## Adding New Data - -### New Region (e.g., Swedish Names) - -```csharp -// In Names.cs - add array -public static readonly string[] SwedishFirstNames = ["Erik", "Lars", "Anna", ...]; -public static readonly string[] SwedishLastNames = ["Andersson", "Johansson", ...]; - -// Update aggregates -public static readonly string[] AllFirstNames = [.. UsFirstNames, .. EuropeanFirstNames, .. SwedishFirstNames]; -public static readonly string[] AllLastNames = [.. UsLastNames, .. EuropeanLastNames, .. SwedishLastNames]; -``` - -### New Company Category - -```csharp -// In Enums/CompanyCategory.cs -public enum CompanyCategory -{ - // ... existing ... - Healthcare, // Add new category - Government -} - -// In Companies.cs - add companies with new category -new("epic.com", "Epic Systems", CompanyCategory.Healthcare, CompanyType.Enterprise, GeographicRegion.NorthAmerica), -``` - -### New Password Pattern - -```csharp -// In Passwords.cs - add to appropriate strength array -// Strong array - add new passphrase style -"correct-horse-battery-staple", // Diceware -"Brave.Tiger.Runs.Fast.42", // Mixed case with numbers -"maple#stream#winter#glow", // Symbol-separated (new) -``` diff --git a/util/Seeder/README.md b/util/Seeder/README.md index 7c856a9a7722..f2344f31fc4f 100644 --- a/util/Seeder/README.md +++ b/util/Seeder/README.md @@ -39,37 +39,22 @@ The Seeder is organized around six core patterns, each with a specific responsib **When to use:** New bulk operations, especially with presets. Provides ultimate flexibility. -**Flow**: Preset JSON → PresetLoader → RecipeBuilder → IStep[] → RecipeExecutor → SeederContext → BulkCommitter - -**Key actors**: - -- **RecipeBuilder**: Fluent API with dependency validation -- **IStep**: Isolated unit of work (CreateOrganizationStep, CreateUsersStep, etc.) -- **RecipeExecutor**: Executes steps, captures statistics, commits -- **RecipeOrchestrator**: Orchestrates recipe building and execution (from presets or options) -- **SeederContext**: Shared mutable state (NOT thread-safe) +**Flow**: Preset JSON → Loader → Builder → Steps → Executor → Context → BulkCommitter **Why this architecture wins**: - **Infrastructure as Code**: JSON presets define complete scenarios - **Mix & Match**: Fixtures + generation in one preset -- **Extensible**: Add entity types via new IStep implementations -- **Future-ready**: Supports custom DSLs on top of RecipeBuilder +- **Extensible**: Add entity types via new step implementations **Phase order**: Org → Owner → Generator → Roster → Users → Groups → Collections → Folders → Ciphers → PersonalCiphers -**Naming**: `{Purpose}Step` classes implementing `IStep` - **Files**: `Pipeline/` folder ---- - #### Factories **Purpose:** Create individual domain entities with cryptographically correct encrypted data. -**Metaphor:** Skilled craftspeople who create one perfect item per call. - **When to use:** Need to create ONE entity (user, cipher, collection) with proper encryption. **Key characteristics:** @@ -79,17 +64,13 @@ The Seeder is organized around six core patterns, each with a specific responsib - Stateless (except for SDK service dependency) - Do NOT interact with database directly -**Naming:** `{Entity}Seeder` class with `Create{Type}{Entity}()` methods - ---- +**Naming:** `{Entity}Seeder` with `Create{Type}{Entity}()` methods #### Recipes **Purpose:** Orchestrate cohesive bulk operations using BulkCopy for performance. -**Metaphor:** Cooking recipes that produce one complete result through coordinated steps. Like baking a three-layer cake - you don't grab three separate recipes and stack them; you follow one comprehensive recipe that orchestrates all the steps. - -**When to use:** Need to create MANY related entities as one cohesive operation (e.g., organization + users + collections + ciphers). +**When to use:** Need to create MANY related entities as one cohesive operation. **Key characteristics:** @@ -100,18 +81,12 @@ The Seeder is organized around six core patterns, each with a specific responsib - **SHALL have a `Seed()` method** that executes the complete recipe - Use method parameters (with defaults) for variations, not separate methods -**Naming:** `{DomainConcept}Recipe` class with primary `Seed()` method - -**Note:** Some existing recipes violate the `Seed()` method convention and will be refactored in the future. - ---- +**Naming:** `{DomainConcept}Recipe` with a `Seed()` method #### Models **Purpose:** DTOs that bridge the gap between SDK encryption format and server storage format. -**Metaphor:** Translators between two different languages (SDK format vs. Server format). - **When to use:** Need data transformation during the encryption pipeline (SDK → Server format). **Key characteristics:** @@ -125,46 +100,33 @@ The Seeder is organized around six core patterns, each with a specific responsib **Purpose:** Create complete, isolated test scenarios for integration tests. -**Metaphor:** Theater scenes with multiple actors and props arranged to tell a complete story. - **When to use:** Need a complete test scenario with proper ID mangling for test isolation. **Key characteristics:** -- Implement `IScene` or `IScene` -- Create complete, realistic test scenarios -- Receive `IManglerService` via DI for test isolation—service handles mangling automatically -- Return `SceneResult` with MangleMap (original→mangled) for test assertions -- Async operations +- Complete, realistic test scenarios with ID mangling for isolation +- Receive mangling service via DI — returns a map of original→mangled values for assertions - CAN modify database state -**Naming:** `{Scenario}Scene` class with `SeedAsync(Request)` method (defined by interface) +**Naming:** `{Scenario}Scene` with a `SeedAsync()` method #### Queries **Purpose:** Read-only data retrieval for test assertions and verification. -**Metaphor:** Information desks that answer questions without changing anything. - **When to use:** Need to READ existing seeded data for verification or follow-up operations. -**Example:** Inviting a user to an organization produces a magic link to accept the invite, a query should be used to retrieve that link because it is easier than interfacing with an external smtp catcher. - **Key characteristics:** -- Implement `IQuery` - Read-only (no database modifications) - Return typed data for test assertions -- Can be used to retrieve side effects due to tested flows -**Naming:** `{DataToRetrieve}Query` class with `Execute(Request)` method (defined by interface) +**Naming:** `{DataToRetrieve}Query` with an `Execute()` method #### Data **Purpose:** Reusable, realistic test data collections that provide the foundation for cipher generation. -**Metaphor:** A well-stocked ingredient pantry that all recipes draw from. - **When to use:** Need realistic, filterable data for cipher content (company names, passwords, usernames). **Key characteristics:** @@ -173,43 +135,22 @@ The Seeder is organized around six core patterns, each with a specific responsib - Filterable by region, type, category - Deterministic (seeded randomness for reproducibility) - Composable across regions -- Enums provide the public API (CompanyType, PasswordStrength, etc.) +- Enums provide the public API -**Folder structure:** See `Data/README.md` for Generators and Distributions details. - -- `Static/` - Read-only data arrays (Companies, Passwords, Names, OrgStructures) -- `Generators/` - Seeded data generators via `GeneratorContext` -- `Distributions/` - Percentage-based selection via `Distribution` -- `Enums/` - Public API enums +See `Data/README.md` for Generators and Distributions details. #### Services **Purpose:** Injectable services that provide cross-cutting functionality via dependency injection. -**`IManglerService`** - Context-aware string mangling for test isolation: - -- `Mangle(string)` - Transforms strings with unique prefixes for collision-free test data -- `GetMangleMap()` - Returns dictionary of original → mangled mappings for assertions -- `IsEnabled` - Indicates whether mangling is active - -**Implementations:** - -- `ManglerService` - Scoped stateful service that adds unique prefixes (`{prefix}+user@domain` for emails, `{prefix}-value` for strings) -- `NoOpManglerService` - Singleton no-op service that returns values unchanged - -**Configuration:** - -- SeederApi: Enabled when `GlobalSettings.TestPlayIdTrackingEnabled` is true -- SeederUtility: Enabled with `--mangle` CLI flag - ---- +Context-aware string mangling for test isolation. Adds unique prefixes to emails and strings for collision-free test data. Enabled via `--mangle` CLI flag (SeederUtility) or application settings (SeederApi). ## Rust SDK Integration The seeder uses FFI calls to the Rust SDK for cryptographically correct encryption: ``` -CipherViewDto → RustSdkService.EncryptCipher() → EncryptedCipherDto → Server Format +CipherView → Rust SDK encrypt → EncryptedCipher → Server Format ``` This ensures seeded data can be decrypted and displayed in the actual Bitwarden clients. diff --git a/util/Seeder/Seeds/README.md b/util/Seeder/Seeds/README.md index 9338281b18bf..feaeb216466e 100644 --- a/util/Seeder/Seeds/README.md +++ b/util/Seeder/Seeds/README.md @@ -15,78 +15,15 @@ Presets wire everything together: org + roster + ciphers. Organized by purpose: | Folder | Purpose | CLI prefix | Example | |--------|---------|------------|---------| | `features/` | Test specific Bitwarden features (SSO, TDE, policies) | `features.` | `--preset features.sso-enterprise` | -| `qa/` | Handcrafted fixture data for visual UI verification | `qa.` | `--preset qa.enterprise-basic` | +| `qa/` | Known users, groups, collections, and permissions you can point a client to | `qa.` | `--preset qa.enterprise-basic` | +| `scale/` | Production-calibrated density presets for performance testing | `scale.` | `--preset scale.md-balanced-sterling-cooper` | | `validation/` | Algorithm verification for seeder development | `validation.` | `--preset validation.density-modeling-power-law-test` | -## Writing Fixtures - -### Organizations - -Just a name and domain. That's it. -Domains must use `.example` (RFC 2606 — guaranteed unresolvable, safe for QA email pipelines). -Plan type and seats are defined in presets, not here. - -See: `fixtures/organizations/redwood-analytics.json` - -### Rosters - -Users, groups, and collections for an org. - -- Users have a `firstName`, `lastName`, and `role` (`owner`, `admin`, `user`, `custom`) -- The Seeder pipeline builds emails as `firstName.lastName@domain`, so `"Family"` + `"Mom"` at domain `acme.example` becomes `family.mom@acme.example` or `a1b2c3d4+family.mom@acme.example` with mangling on -- Groups reference users by that same email prefix (e.g. `"family.mom"`) -- Collections assign permissions to groups or individual users (`readOnly`, `hidePasswords`, `manage` — all default false) - -See: `starter-team.json` (minimal), `family.json` (groups + collections), `dunder-mifflin.json` (58-user enterprise) - -### Ciphers - -Vault items. Each item needs a `type` and `name`. +For the full preset catalog with per-preset details, see [docs/presets.md](docs/presets.md). -| Type | Required Object | Description | -| ------------ | --------------- | -------------------------- | -| `login` | `login` | Website credentials + URIs | -| `card` | `card` | Payment card details | -| `identity` | `identity` | Personal identity info | -| `secureNote` | — | Uses `notes` field only | -| `sshKey` | `sshKey` | SSH key credentials | +For verification queries used during density development, see [docs/verification.md](docs/verification.md). -See: `fixtures/ciphers/enterprise-basic.json` - -## Naming Conventions - -| Element | Pattern | Example | -| ----------- | ------------------ | --------------------- | -| File names | kebab-case | `banking-logins.json` | -| Item names | Title case, unique | `Chase Bank Login` | -| User refs | firstName.lastName | `jane.doe` | -| Org domains | .example | `acme.example` | - -## Validation - -Your editor validates against `$schema` automatically — errors show up as red squiggles. Build also catches schema violations: - -```bash -dotnet build util/Seeder/Seeder.csproj -``` - -## QA Migration - -Mapping from legacy QA test fixtures to seeder presets: - -| Legacy Source | Seeder Preset | -|--------------|---------------| -| `CollectionPermissionsOrg.json` | `qa.collection-permissions-enterprise` | -| `EnterpriseOrg.json` | `qa.enterprise-basic` | -| `SsoOrg.json` | `features.sso-enterprise` | -| `TDEOrg.json` | `features.tde-enterprise` | -| Policy Org (Confluence) | `features.policy-enterprise` | -| `FamiliesOrg.json` | `qa.families-basic` | - -**Planned:** `qa.free-personal-vault`, `qa.premium-personal-vault`, `features.secrets-manager-enterprise`, `qa.free-org-basic` +## Writing Fixtures -## Security +For how to create new organization, roster, and cipher fixtures, see [docs/fixtures.md](docs/fixtures.md). -- Use fictional names/addresses -- Never commit real passwords or PII -- Never seed production databases diff --git a/util/Seeder/Seeds/docs/fixtures.md b/util/Seeder/Seeds/docs/fixtures.md new file mode 100644 index 000000000000..68022fdce8a1 --- /dev/null +++ b/util/Seeder/Seeds/docs/fixtures.md @@ -0,0 +1,49 @@ +# Writing Fixtures + +Hand-crafted JSON fixtures for Bitwarden Seeder test data. Add a `$schema` line for editor validation. + +## Organizations + +Just a name and domain. Domains must use `.example` (RFC 2606 — guaranteed unresolvable, safe for email pipelines). Plan type and seats are defined in presets, not here. + +See: `fixtures/organizations/redwood-analytics.json` + +## Rosters + +Users, groups, and collections for an org. + +- Users have a `firstName`, `lastName`, and `role` (`owner`, `admin`, `user`, `custom`) +- The Seeder builds emails as `firstName.lastName@domain`, so `"Family"` + `"Mom"` at domain `acme.example` becomes `family.mom@acme.example` or `a1b2c3d4+family.mom@acme.example` with mangling +- Groups reference users by that same email prefix (e.g. `"family.mom"`) +- Collections assign permissions to groups or individual users (`readOnly`, `hidePasswords`, `manage` — all default false) + +See: `starter-team.json` (minimal), `family.json` (groups + collections), `dunder-mifflin.json` (58-user enterprise) + +## Ciphers + +Vault items. Each item needs a `type` and `name`. + +See: `fixtures/ciphers/enterprise-basic.json` + +## Naming Conventions + +| Element | Pattern | Example | +| ----------- | ------------------ | --------------------- | +| File names | kebab-case | `banking-logins.json` | +| Item names | Title case, unique | `Chase Bank Login` | +| User refs | firstName.lastName | `jane.doe` | +| Org domains | .example | `acme.example` | + +## Validation + +Your editor validates against `$schema` automatically. Build also catches schema violations: + +```bash +dotnet build util/Seeder/Seeder.csproj +``` + +## Security + +- Use fictional names/addresses +- Never commit real passwords or PII +- Never seed production databases diff --git a/util/Seeder/Seeds/docs/presets.md b/util/Seeder/Seeds/docs/presets.md new file mode 100644 index 000000000000..24eee009af99 --- /dev/null +++ b/util/Seeder/Seeds/docs/presets.md @@ -0,0 +1,83 @@ +# Preset Catalog + +Complete catalog of all seeder presets, organized by purpose. Use `--mangle` to avoid collisions with existing data. + +## Features + +Test specific Bitwarden features. Fixture-based data for deterministic results. + +```bash +dotnet run -- seed --preset features.{name} --mangle +``` + +| Preset | Plan | Features Enabled | Org Fixture | Roster | Ciphers | +| ----------------- | ------------------- | -------------------------------------------------- | ---------------- | ------------ | --------- | +| sso-enterprise | enterprise-annually | SSO (OIDC, masterPassword) + requireSso policy | verdant-health | starter-team | sso-vault | +| tde-enterprise | enterprise-annually | SSO (OIDC, trustedDevices/TDE) + requireSso policy | obsidian-labs | starter-team | tde-vault | +| policy-enterprise | enterprise-annually | All policies except requireSso and require2fa | pinnacle-designs | starter-team | — | + +`policy-enterprise` has no ciphers — it exists purely for testing policy enforcement. + +## QA + +Known users, groups, collections, and permissions you can point a client to. + +```bash +dotnet run -- seed --preset qa.{name} --mangle +``` + +| Preset | Plan | Seats | Org Fixture | Roster | Ciphers | Use Case | +| --------------------------------- | ------------------- | ----- | ----------------- | ---------------------- | ---------------------- | ---------------------------------- | +| enterprise-basic | enterprise-annually | 10 | redwood-analytics | enterprise-basic | enterprise-basic | Standard enterprise org | +| collection-permissions-enterprise | enterprise-annually | 10 | cobalt-logistics | collection-permissions | collection-permissions | Permission edge cases | +| dunder-mifflin-enterprise-full | enterprise-annually | 70 | dunder-mifflin | dunder-mifflin | autofill-testing | Large handcrafted org | +| families-basic | families-annually | 6 | adams-family | family | 150 generated | Families plan with personal vaults | +| stark-free-basic | free | 2 | stark-industries | 1 generated user | autofill-testing | Free plan personal vault | + +`families-basic` and `stark-free-basic` mix fixtures with generated data (ciphers and personal ciphers). + +## Scale + +Production-calibrated presets with density modeling. Realistic relationship patterns (group membership, collection fan-out, permission distribution, cipher assignment) across 5 tiers. + +```bash +dotnet run -- seed --preset scale.{name} --mangle +``` + +| Preset | Tier | Archetype | Users | Groups | Collections | Ciphers | Plan | +| ------------------------------- | ---- | --------------------------- | ------ | ------ | ----------- | ------- | ------------------- | +| xs-central-perk | XS | Family starter | 6 | 2 | 10 | 200 | families-annually | +| sm-balanced-planet-express | SM | Small balanced | 50 | 8 | 100 | 750 | teams-annually | +| sm-highperm-bluth-company | SM | Small hierarchical | 50 | 4 | 25 | 500 | teams-annually | +| md-balanced-sterling-cooper | MD | Mid-market balanced | 250 | 50 | 500 | 5,000 | enterprise-annually | +| md-highcollection-umbrella-corp | MD | Collection-heavy | 200 | 8 | 800 | 3,000 | enterprise-annually | +| lg-balanced-wayne-enterprises | LG | Large balanced | 1,000 | 100 | 2,000 | 10,000 | enterprise-annually | +| lg-highperm-tyrell-corp | LG | High permission density | 2,500 | 75 | 2,300 | 17,000 | enterprise-annually | +| xl-highperm-weyland-yutani | XL | Mega corp, many groups | 5,000 | 500 | 1,200 | 15,000 | enterprise-annually | +| xl-broad-initech | XL | Mega corp, many collections | 10,000 | 5 | 12,000 | 15,000 | enterprise-annually | + +**Notes:** + +- The XS preset uses `families-annually`, which hides the Groups UI even though the seeder creates groups. +- **Cipher types**: Most use `realistic` (60% Login, 15% SecureNote, 12% Card, 10% Identity, 3% SSHKey). Umbrella Corp uses `documentationHeavy` (40/40 Login/SecureNote). Tyrell Corp uses `developerFocused` (50% Login, 20% SSHKey). +- **Personal ciphers**: Sterling Cooper and Wayne Enterprises use `realistic` distribution. Weyland-Yutani uses `lightUsage`. Use `heavyUsage` only for small/mid orgs — at XL scale it produces 300K+ ciphers and will timeout. +- **Folders**: Wayne Enterprises uses `enterprise` folder distribution. Weyland-Yutani uses `minimal`. + +For per-preset expected values and verification queries, see [verification.md](verification.md). + +## Validation + +Algorithm verification for seeder development. Not for general use. + +```bash +dotnet run -- seed --preset validation.{name} --mangle +``` + +| Preset | Tests | +| ---------------------------------- | ------------------------------------------------------------- | +| density-modeling-power-law-test | PowerLaw group membership, fan-out, permissions, orphans | +| density-modeling-mega-group-test | MegaGroup membership, FrontLoaded fan-out, all-group access | +| density-modeling-empty-groups-test | EmptyGroupRate exclusion from CollectionGroup | +| density-modeling-no-density-test | Backward compatibility (no density block = original behavior) | + +For expected values and verification queries, see [verification.md](verification.md). diff --git a/util/Seeder/Seeds/docs/verification.md b/util/Seeder/Seeds/docs/verification.md new file mode 100644 index 000000000000..34e3996d3fe6 --- /dev/null +++ b/util/Seeder/Seeds/docs/verification.md @@ -0,0 +1,371 @@ +# Verification Queries + +SQL queries for verifying density algorithm output against expected values. Run after seeding a scale or validation preset. Developer-facing — not needed for normal seeder usage. + +## Using Claude Code for Verification + +The fastest way to verify a preset is to let Claude Code run the queries and compare results for you. + +1. Seed the preset: + ```bash + dotnet run -- seed --preset scale.md-balanced-sterling-cooper --mangle + ``` +2. Paste the seeder output (with Org ID) into Claude Code and ask: + > Run Q1-Q8 against Org ID {id} and compare results to the Sterling Cooper expected values in verification.md +3. Claude Code runs each query via the `bitwarden-mssql` skill, formats results as a pass/fail table, and flags any deviations beyond tolerance. + +This is how the density algorithms were originally validated — Claude Code ran every query, computed pass/fail against the expected values below, and flagged distribution bugs that were then fixed in the same session. + +### Manual Usage + +Run Q0 first to get the Organization ID, then paste it into the remaining queries. + +## Queries + +### Q0: Find the Organization ID + +```sql +SELECT Id, [Name] +FROM [dbo].[Organization] WITH (NOLOCK) +WHERE [Name] = 'PASTE_ORG_NAME_HERE'; +``` + +### Q1: Group Membership Distribution + +Verifies `membership.shape` and `membership.skew`. Member counts should reflect Uniform (roughly equal), PowerLaw (decaying), or MegaGroup (one dominant). + +```sql +DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE'; + +SELECT + G.[Name], + COUNT(GU.OrganizationUserId) AS Members +FROM [dbo].[Group] G WITH (NOLOCK) +LEFT JOIN [dbo].[GroupUser] GU WITH (NOLOCK) ON G.Id = GU.GroupId +WHERE G.OrganizationId = @OrgId +GROUP BY G.[Name] +ORDER BY Members DESC; +``` + +### Q2: CollectionGroup Count + +Verifies `collectionFanOut`. Total should fall within `collections * min` to `collections * max`. + +```sql +DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE'; + +SELECT COUNT(*) AS CollectionGroupCount +FROM [dbo].[CollectionGroup] CG WITH (NOLOCK) +INNER JOIN [dbo].[Collection] C WITH (NOLOCK) ON CG.CollectionId = C.Id +WHERE C.OrganizationId = @OrgId; +``` + +### Q3: Permission Distribution + +Verifies `permissions` weights. Zero-weight permissions must produce zero records. + +```sql +DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE'; + +SELECT + 'CollectionUser' AS [Source], + COUNT(*) AS Total, + SUM(CASE WHEN CU.ReadOnly = 1 THEN 1 ELSE 0 END) AS ReadOnly, + SUM(CASE WHEN CU.Manage = 1 THEN 1 ELSE 0 END) AS Manage, + SUM(CASE WHEN CU.HidePasswords = 1 THEN 1 ELSE 0 END) AS HidePasswords, + SUM(CASE WHEN CU.ReadOnly = 0 AND CU.Manage = 0 AND CU.HidePasswords = 0 THEN 1 ELSE 0 END) AS ReadWrite +FROM [dbo].[CollectionUser] CU WITH (NOLOCK) +INNER JOIN [dbo].[OrganizationUser] OU WITH (NOLOCK) ON CU.OrganizationUserId = OU.Id +WHERE OU.OrganizationId = @OrgId +UNION ALL +SELECT + 'CollectionGroup', + COUNT(*), + SUM(CASE WHEN CG.ReadOnly = 1 THEN 1 ELSE 0 END), + SUM(CASE WHEN CG.Manage = 1 THEN 1 ELSE 0 END), + SUM(CASE WHEN CG.HidePasswords = 1 THEN 1 ELSE 0 END), + SUM(CASE WHEN CG.ReadOnly = 0 AND CG.Manage = 0 AND CG.HidePasswords = 0 THEN 1 ELSE 0 END) +FROM [dbo].[CollectionGroup] CG WITH (NOLOCK) +INNER JOIN [dbo].[Collection] C WITH (NOLOCK) ON CG.CollectionId = C.Id +WHERE C.OrganizationId = @OrgId; +``` + +### Q4: Orphan Ciphers + +Verifies `cipherAssignment.orphanRate`. Orphans have no CollectionCipher assignment. + +```sql +DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE'; + +SELECT + COUNT(*) AS TotalCiphers, + SUM(CASE WHEN CC.CipherId IS NULL THEN 1 ELSE 0 END) AS Orphans +FROM [dbo].[Cipher] CI WITH (NOLOCK) +LEFT JOIN (SELECT DISTINCT CipherId FROM [dbo].[CollectionCipher] WITH (NOLOCK)) CC + ON CI.Id = CC.CipherId +WHERE CI.OrganizationId = @OrgId; +``` + +### Q5: Direct Access Ratio + +Verifies `directAccessRatio`. Ratio is `int(userCount * directAccessRatio) / userCount`, so small orgs may show truncation. + +```sql +DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE'; + +SELECT + TotalOrgUsers, + UsersWithDirectAccess, + CAST(UsersWithDirectAccess AS FLOAT) / NULLIF(TotalOrgUsers, 0) AS DirectAccessRatio +FROM ( + SELECT + (SELECT COUNT(*) FROM [dbo].[OrganizationUser] WITH (NOLOCK) + WHERE OrganizationId = @OrgId AND [Status] = 2) AS TotalOrgUsers, + (SELECT COUNT(DISTINCT CU.OrganizationUserId) + FROM [dbo].[CollectionUser] CU WITH (NOLOCK) + INNER JOIN [dbo].[OrganizationUser] OU WITH (NOLOCK) ON CU.OrganizationUserId = OU.Id + WHERE OU.OrganizationId = @OrgId) AS UsersWithDirectAccess +) T; +``` + +### Q6: Cipher Distribution Shape + +Verifies `cipherAssignment.skew`. CV near 0 = uniform, CV high = heavyRight. + +```sql +DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE'; + +SELECT + COUNT(*) AS Collections, + MIN(CipherCount) AS MinCiphers, + MAX(CipherCount) AS MaxCiphers, + AVG(CipherCount) AS AvgCiphers, + CASE WHEN AVG(CipherCount) > 0 + THEN STDEV(CipherCount) / AVG(CipherCount) + ELSE 0 + END AS CoefficientOfVariation +FROM ( + SELECT C.Id, COUNT(CC.CipherId) AS CipherCount + FROM [dbo].[Collection] C WITH (NOLOCK) + LEFT JOIN [dbo].[CollectionCipher] CC WITH (NOLOCK) ON C.Id = CC.CollectionId + WHERE C.OrganizationId = @OrgId + GROUP BY C.Id +) T; +``` + +### Q7: Collections-Per-User Distribution + +Verifies `userCollections`. CV near 0 = uniform, CV > 0.5 = power-law. + +```sql +DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE'; + +SELECT + COUNT(*) AS UsersWithDirectAccess, + MIN(CollectionCount) AS MinCollections, + MAX(CollectionCount) AS MaxCollections, + AVG(CollectionCount) AS AvgCollections, + CASE WHEN AVG(CollectionCount) > 0 + THEN STDEV(CollectionCount) / AVG(CollectionCount) + ELSE 0 + END AS CoefficientOfVariation +FROM ( + SELECT CU.OrganizationUserId, COUNT(DISTINCT CU.CollectionId) AS CollectionCount + FROM [dbo].[CollectionUser] CU WITH (NOLOCK) + INNER JOIN [dbo].[OrganizationUser] OU WITH (NOLOCK) ON CU.OrganizationUserId = OU.Id + WHERE OU.OrganizationId = @OrgId + GROUP BY CU.OrganizationUserId +) T; +``` + +### Q8: Multi-Collection Ciphers + +Verifies `cipherAssignment.multiCollectionRate`. Ratio should approximate the configured rate. + +```sql +DECLARE @OrgId UNIQUEIDENTIFIER = 'PASTE_ORG_ID_HERE'; + +SELECT + COUNT(*) AS TotalAssignedCiphers, + SUM(CASE WHEN CollectionCount > 1 THEN 1 ELSE 0 END) AS MultiCollectionCiphers, + MAX(CollectionCount) AS MaxCollectionsPerCipher +FROM ( + SELECT CC.CipherId, COUNT(DISTINCT CC.CollectionId) AS CollectionCount + FROM [dbo].[CollectionCipher] CC WITH (NOLOCK) + INNER JOIN [dbo].[Cipher] CI WITH (NOLOCK) ON CC.CipherId = CI.Id + WHERE CI.OrganizationId = @OrgId + GROUP BY CC.CipherId +) T; +``` + +--- + +## Scale Preset Expected Values + +### 1. Central Perk (XS) + +| Check | Expected | +| --------------------- | ----------------------------------------------------------------------------- | +| Membership shape | Uniform — 2 groups with ~3 members each. | +| CollectionGroups | 10-20 records. Uniform fan-out of 1-2 groups per collection. | +| Permissions | ~50% Manage, ~40% ReadWrite, ~10% ReadOnly, 0% HidePasswords. | +| Orphan ciphers | 0 of 200 (0% orphan rate). | +| Direct access ratio | 0.8 — ~80% of access paths are direct CollectionUser. | +| Collections per user | Uniform 1-3. Min=1, Max=3, Avg=2. | +| Multi-collection rate | 20% of 200 non-orphan ciphers in 2 collections. ~40 multi-collection ciphers. | + +### 2. Planet Express (SM) + +| Check | Expected | +| --------------------- | ------------------------------------------------------------------------------- | +| Membership shape | PowerLaw (skew 0.4) — first group largest, gentle decay across 8 groups. | +| CollectionGroups | 200-400 records. Uniform fan-out of 2-4 groups per collection. | +| Permissions | ~40% ReadOnly, ~30% ReadWrite, ~25% Manage, ~5% HidePasswords. | +| Orphan ciphers | ~37 of 750 (5% orphan rate). | +| Direct access ratio | 0.7 — ~70% of access paths are direct CollectionUser. | +| Collections per user | PowerLaw 1-5 (skew 0.3). First users get up to 5, most get 1-2. CV > 0.3. | +| Multi-collection rate | 15% of ~713 non-orphan ciphers in 2 collections. ~107 multi-collection ciphers. | + +### 3. Bluth Company (SM) + +| Check | Expected | +| --------------------- | ------------------------------------------------------------------------------ | +| Membership shape | PowerLaw (skew 0.7) — steep decay across 4 groups. First group dominant. | +| CollectionGroups | 25-125 records. PowerLaw fan-out of 1-5 groups per collection. | +| Permissions | ~82% ReadOnly, ~9% ReadWrite, ~5% Manage, ~4% HidePasswords. | +| Orphan ciphers | ~75 of 500 (15% orphan rate). | +| Direct access ratio | 0.6 — ~60% of access paths are direct CollectionUser. | +| Collections per user | Uniform 1-3. Min=1, Max=3, Avg=2. | +| Multi-collection rate | 10% of ~425 non-orphan ciphers in 2 collections. ~42 multi-collection ciphers. | + +### 4. Sterling Cooper (MD) + +| Check | Expected | +| --------------------- | --------------------------------------------------------------------------- | +| Membership shape | PowerLaw (skew 0.6) — moderate decay across 50 groups. | +| CollectionGroups | 500-2,500 records. PowerLaw fan-out of 1-5 active groups per collection. | +| Permissions | ~55% ReadOnly, ~20% ReadWrite, ~15% Manage, ~10% HidePasswords. | +| Orphan ciphers | ~400 of 5,000 (8% orphan rate). | +| Direct access ratio | 0.5 — roughly even split between direct and group-mediated access. | +| Empty group rate | ~26% — ~13 of 50 groups have 0 members due to power-law tail truncation. | +| Collections per user | PowerLaw 1-10 (skew 0.5). First users get up to 10, most get 1-2. CV > 0.5. | +| Multi-collection rate | 20% of ~4,600 non-orphan ciphers in 2-3 collections. Max 3 per cipher. | + +### 5. Umbrella Corp (MD) + +| Check | Expected | +| --------------------- | -------------------------------------------------------------------------------------- | +| Membership shape | MegaGroup (skew 0.5) — group 1 has ~72% of members, remaining 7 split the rest evenly. | +| CollectionGroups | 800-2,400 records. FrontLoaded fan-out of 1-3 groups per collection. | +| Permissions | ~50% ReadWrite, ~20% Manage, ~20% ReadOnly, ~10% HidePasswords. | +| Orphan ciphers | ~600 of 3,000 (20% orphan rate). | +| Direct access ratio | 0.9 — ~90% of access paths are direct CollectionUser. | +| Collections per user | PowerLaw 1-15 (skew 0.6). First users get up to 15, most get 1-2. CV > 0.5. | +| Multi-collection rate | 25% of ~2,400 non-orphan ciphers in 2-3 collections. Max 3 per cipher. | + +### 6. Wayne Enterprises (LG) + +| Check | Expected | +| --------------------- | ------------------------------------------------------------------------------ | +| Membership shape | PowerLaw (skew 0.7) — steep decay across 100 groups. First groups much larger. | +| CollectionGroups | 2,000-10,000 records. PowerLaw fan-out of 1-5 active groups per collection. | +| Permissions | ~82% ReadOnly, ~9% ReadWrite, ~5% Manage, ~4% HidePasswords. | +| Orphan ciphers | ~1,000 of 10,000 (10% orphan rate). | +| Direct access ratio | 0.5 — roughly even split between direct and group-mediated access. | +| Empty group rate | ~30% — ~30 of 100 groups have 0 members due to power-law tail truncation. | +| Collections per user | PowerLaw 1-25 (skew 0.6). First users get up to 25, most get 1-2. CV > 0.5. | +| Multi-collection rate | 25% of ~9,000 non-orphan ciphers in 2-4 collections. Max 4 per cipher. | + +### 7. Tyrell Corp (LG) + +| Check | Expected | +| --------------------- | -------------------------------------------------------------------------------- | +| Membership shape | PowerLaw (skew 0.8) — very steep decay across 75 groups. First group very large. | +| CollectionGroups | 4,600-18,400 records. PowerLaw fan-out of 2-8 active groups per collection. | +| Permissions | ~82% ReadOnly, ~9% ReadWrite, ~5% Manage, ~4% HidePasswords. | +| Orphan ciphers | ~2,550 of 17,000 (15% orphan rate). | +| Direct access ratio | 0.6 — ~60% of access paths are direct CollectionUser. | +| Empty group rate | 20% — ~15 of 75 groups have 0 members. | +| Collections per user | PowerLaw 1-30 (skew 0.7). First users get up to 30, most get 1-2. CV > 0.5. | +| Multi-collection rate | 30% of ~14,450 non-orphan ciphers in 2-4 collections. Max 4 per cipher. | + +### 8. Weyland-Yutani (XL) + +| Check | Expected | +| --------------------- | ------------------------------------------------------------------------------------ | +| Membership shape | PowerLaw (skew 0.8) — very steep decay across 500 groups. Long tail of small groups. | +| CollectionGroups | 1,200-3,600 records. PowerLaw fan-out of 1-3 active groups per collection. | +| Permissions | ~55% ReadWrite, ~25% ReadOnly, ~10% Manage, ~10% HidePasswords. | +| Orphan ciphers | ~1,500 of 15,000 (10% orphan rate). | +| Direct access ratio | 0.4 — majority of access is group-mediated. | +| Empty group rate | ~68% — ~341 of 500 groups have 0 members due to power-law tail truncation. | +| Collections per user | PowerLaw 1-50 (skew 0.8). First users get up to 50, most get 1-2. CV > 0.5. | +| Multi-collection rate | 30% of ~13,500 non-orphan ciphers in 2-5 collections. Max 5 per cipher. | + +### 9. Initech (XL) + +| Check | Expected | +| --------------------- | --------------------------------------------------------------------------------------- | +| Membership shape | MegaGroup (skew 0.95) — group 1 has ~93% of members, remaining 4 split the rest evenly. | +| CollectionGroups | 0 records. DirectAccessRatio is 1.0, so CollectionGroup creation is skipped entirely. | +| Permissions | ~30% Manage, ~30% ReadWrite, ~30% ReadOnly, ~10% HidePasswords. | +| Orphan ciphers | ~12,750 of 15,000 (85% orphan rate). | +| Direct access ratio | 1.0 — 100% of access paths are direct CollectionUser. | +| Collections per user | PowerLaw 1-20 (skew 0.5). First users get up to 20, most get 1. CV > 0.2. | +| Multi-collection rate | 15% of ~2,250 non-orphan ciphers in 2-3 collections. Max 3 per cipher. | + +--- + +## Validation Preset Expected Values + +### 1. Power-Law Distribution + +```bash +dotnet run -- seed --preset validation.density-modeling-power-law-test --mangle +``` + +| Check | Expected | +| ----------------- | -------------------------------------------------------------------------------------- | +| Groups | 10 groups. First has ~50 members, decays to 1. Last 2 have 0 members (20% empty rate). | +| CollectionGroups | > 0 records. First collections have more groups assigned (PowerLaw fan-out). | +| Permissions | ~50% ReadOnly, ~30% ReadWrite, ~15% Manage, ~5% HidePasswords. | +| Orphan ciphers | ~50 of 500 (10% orphan rate). | +| DirectAccessRatio | 0.6 — roughly 60% of access paths are direct CollectionUser. | + +### 2. MegaGroup Distribution + +```bash +dotnet run -- seed --preset validation.density-modeling-mega-group-test --mangle +``` + +| Check | Expected | +| ---------------- | ------------------------------------------------------------------------ | +| Groups | 5 groups. Group 1 has ~90 members (90.5%). Groups 2-5 split ~10 members. | +| CollectionUsers | 0 records. DirectAccessRatio is 0.0 — all access via groups. | +| CollectionGroups | > 0. First 10 collections get 3 groups (FrontLoaded), rest get 1. | +| Permissions | 25% each for ReadOnly, ReadWrite, Manage, HidePasswords (even split). | + +### 3. Empty Groups + +```bash +dotnet run -- seed --preset validation.density-modeling-empty-groups-test --mangle +``` + +| Check | Expected | +| ----------------- | ----------------------------------------------------------------------- | +| Groups | 10 groups total. 5 with ~10 members each, 5 with 0 members (50% empty). | +| CollectionGroups | Only reference the 5 non-empty groups. | +| DirectAccessRatio | 0.5 — roughly half of users get direct CollectionUser records. | + +### 4. No Density (Baseline) + +```bash +dotnet run -- seed --preset validation.density-modeling-no-density-test --mangle +``` + +| Check | Expected | +| ---------------- | ---------------------------------------------------------------------------------------- | +| Groups | 5 groups with ~10 members each (uniform round-robin). | +| CollectionGroups | 0 records. No density = no CollectionGroup generation. | +| Permissions | First assignment per user is Manage, subsequent are ReadOnly (original cycling pattern). | +| Orphan ciphers | 0. Every cipher assigned to at least one collection. | diff --git a/util/Seeder/Seeds/fixtures/presets/scale/lg-balanced-wayne-enterprises.json b/util/Seeder/Seeds/fixtures/presets/scale/lg-balanced-wayne-enterprises.json new file mode 100644 index 000000000000..ec9bf6786617 --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/scale/lg-balanced-wayne-enterprises.json @@ -0,0 +1,23 @@ +{ + "$schema": "../../../schemas/preset.schema.json", + "organization": { + "name": "Wayne Enterprises", + "domain": "wayneenterprises.example", + "planType": "enterprise-annually", + "seats": 1200 + }, + "users": { "count": 1000, "realisticStatusMix": true }, + "groups": { "count": 100 }, + "collections": { "count": 2000 }, + "density": { + "membership": { "shape": "powerLaw", "skew": 0.7 }, + "collectionFanOut": { "min": 1, "max": 5, "shape": "powerLaw", "emptyGroupRate": 0.15 }, + "directAccessRatio": 0.5, + "permissions": { "readOnly": 0.82, "readWrite": 0.09, "manage": 0.05, "hidePasswords": 0.04 }, + "cipherAssignment": { "skew": "heavyRight", "orphanRate": 0.1, "multiCollectionRate": 0.25, "maxCollectionsPerCipher": 4 }, + "userCollections": { "min": 1, "max": 25, "shape": "powerLaw", "skew": 0.6 }, + "personalCiphers": { "shape": "realistic" }, + "folders": { "shape": "enterprise" } + }, + "ciphers": { "count": 10000 } +} diff --git a/util/Seeder/Seeds/fixtures/presets/scale/lg-highperm-tyrell-corp.json b/util/Seeder/Seeds/fixtures/presets/scale/lg-highperm-tyrell-corp.json new file mode 100644 index 000000000000..7882b74f2201 --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/scale/lg-highperm-tyrell-corp.json @@ -0,0 +1,22 @@ +{ + "$schema": "../../../schemas/preset.schema.json", + "organization": { + "name": "Tyrell Corp", + "domain": "tyrellcorp.example", + "planType": "enterprise-annually", + "seats": 3000 + }, + "users": { "count": 2500, "realisticStatusMix": true }, + "groups": { "count": 75 }, + "collections": { "count": 2300 }, + "density": { + "membership": { "shape": "powerLaw", "skew": 0.8 }, + "collectionFanOut": { "min": 2, "max": 8, "shape": "powerLaw", "emptyGroupRate": 0.2 }, + "directAccessRatio": 0.6, + "permissions": { "readOnly": 0.82, "readWrite": 0.09, "manage": 0.05, "hidePasswords": 0.04 }, + "cipherAssignment": { "skew": "heavyRight", "orphanRate": 0.15, "multiCollectionRate": 0.30, "maxCollectionsPerCipher": 4 }, + "userCollections": { "min": 1, "max": 30, "shape": "powerLaw", "skew": 0.7 }, + "cipherTypes": { "preset": "developerFocused" } + }, + "ciphers": { "count": 17000 } +} diff --git a/util/Seeder/Seeds/fixtures/presets/scale/md-balanced-sterling-cooper.json b/util/Seeder/Seeds/fixtures/presets/scale/md-balanced-sterling-cooper.json new file mode 100644 index 000000000000..0617e390afc3 --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/scale/md-balanced-sterling-cooper.json @@ -0,0 +1,22 @@ +{ + "$schema": "../../../schemas/preset.schema.json", + "organization": { + "name": "Sterling Cooper", + "domain": "sterlingcooper.example", + "planType": "enterprise-annually", + "seats": 300 + }, + "users": { "count": 250, "realisticStatusMix": true }, + "groups": { "count": 50 }, + "collections": { "count": 500 }, + "density": { + "membership": { "shape": "powerLaw", "skew": 0.6 }, + "collectionFanOut": { "min": 1, "max": 5, "shape": "powerLaw", "emptyGroupRate": 0.1 }, + "directAccessRatio": 0.5, + "permissions": { "readOnly": 0.55, "readWrite": 0.20, "manage": 0.15, "hidePasswords": 0.10 }, + "cipherAssignment": { "skew": "heavyRight", "orphanRate": 0.08, "multiCollectionRate": 0.20, "maxCollectionsPerCipher": 3 }, + "userCollections": { "min": 1, "max": 10, "shape": "powerLaw", "skew": 0.5 }, + "personalCiphers": { "shape": "realistic" } + }, + "ciphers": { "count": 5000 } +} diff --git a/util/Seeder/Seeds/fixtures/presets/scale/md-highcollection-umbrella-corp.json b/util/Seeder/Seeds/fixtures/presets/scale/md-highcollection-umbrella-corp.json new file mode 100644 index 000000000000..4b9a9784d334 --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/scale/md-highcollection-umbrella-corp.json @@ -0,0 +1,22 @@ +{ + "$schema": "../../../schemas/preset.schema.json", + "organization": { + "name": "Umbrella Corp", + "domain": "umbrellacorp.example", + "planType": "enterprise-annually", + "seats": 250 + }, + "users": { "count": 200, "realisticStatusMix": true }, + "groups": { "count": 8 }, + "collections": { "count": 800 }, + "density": { + "membership": { "shape": "megaGroup", "skew": 0.5 }, + "collectionFanOut": { "min": 1, "max": 3, "shape": "frontLoaded" }, + "directAccessRatio": 0.9, + "permissions": { "readWrite": 0.50, "manage": 0.20, "readOnly": 0.20, "hidePasswords": 0.10 }, + "cipherAssignment": { "skew": "heavyRight", "orphanRate": 0.2, "multiCollectionRate": 0.25, "maxCollectionsPerCipher": 3 }, + "userCollections": { "min": 1, "max": 15, "shape": "powerLaw", "skew": 0.6 }, + "cipherTypes": { "preset": "documentationHeavy" } + }, + "ciphers": { "count": 3000 } +} diff --git a/util/Seeder/Seeds/fixtures/presets/scale/sm-balanced-planet-express.json b/util/Seeder/Seeds/fixtures/presets/scale/sm-balanced-planet-express.json new file mode 100644 index 000000000000..e2113fb85c14 --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/scale/sm-balanced-planet-express.json @@ -0,0 +1,21 @@ +{ + "$schema": "../../../schemas/preset.schema.json", + "organization": { + "name": "Planet Express", + "domain": "planetexpress.example", + "planType": "teams-annually", + "seats": 75 + }, + "users": { "count": 50, "realisticStatusMix": true }, + "groups": { "count": 8 }, + "collections": { "count": 100 }, + "density": { + "membership": { "shape": "powerLaw", "skew": 0.4 }, + "collectionFanOut": { "min": 2, "max": 4, "shape": "uniform" }, + "directAccessRatio": 0.7, + "permissions": { "readOnly": 0.40, "readWrite": 0.30, "manage": 0.25, "hidePasswords": 0.05 }, + "cipherAssignment": { "skew": "heavyRight", "orphanRate": 0.05, "multiCollectionRate": 0.15, "maxCollectionsPerCipher": 2 }, + "userCollections": { "min": 1, "max": 5, "shape": "powerLaw", "skew": 0.3 } + }, + "ciphers": { "count": 750 } +} diff --git a/util/Seeder/Seeds/fixtures/presets/scale/sm-highperm-bluth-company.json b/util/Seeder/Seeds/fixtures/presets/scale/sm-highperm-bluth-company.json new file mode 100644 index 000000000000..1b5c81b4e2fa --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/scale/sm-highperm-bluth-company.json @@ -0,0 +1,21 @@ +{ + "$schema": "../../../schemas/preset.schema.json", + "organization": { + "name": "Bluth Company", + "domain": "bluthcompany.example", + "planType": "teams-annually", + "seats": 75 + }, + "users": { "count": 50, "realisticStatusMix": true }, + "groups": { "count": 4 }, + "collections": { "count": 25 }, + "density": { + "membership": { "shape": "powerLaw", "skew": 0.7 }, + "collectionFanOut": { "min": 1, "max": 5, "shape": "powerLaw" }, + "directAccessRatio": 0.6, + "permissions": { "readOnly": 0.82, "readWrite": 0.09, "manage": 0.05, "hidePasswords": 0.04 }, + "cipherAssignment": { "skew": "heavyRight", "orphanRate": 0.15, "multiCollectionRate": 0.10, "maxCollectionsPerCipher": 2 }, + "userCollections": { "min": 1, "max": 3, "shape": "uniform" } + }, + "ciphers": { "count": 500 } +} diff --git a/util/Seeder/Seeds/fixtures/presets/scale/xl-broad-initech.json b/util/Seeder/Seeds/fixtures/presets/scale/xl-broad-initech.json new file mode 100644 index 000000000000..76520df9fbbd --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/scale/xl-broad-initech.json @@ -0,0 +1,21 @@ +{ + "$schema": "../../../schemas/preset.schema.json", + "organization": { + "name": "Initech", + "domain": "initech.example", + "planType": "enterprise-annually", + "seats": 12000 + }, + "users": { "count": 10000, "realisticStatusMix": true }, + "groups": { "count": 5 }, + "collections": { "count": 12000 }, + "density": { + "membership": { "shape": "megaGroup", "skew": 0.95 }, + "collectionFanOut": { "min": 1, "max": 3, "shape": "frontLoaded" }, + "directAccessRatio": 1.0, + "permissions": { "manage": 0.30, "readWrite": 0.30, "readOnly": 0.30, "hidePasswords": 0.10 }, + "cipherAssignment": { "skew": "uniform", "orphanRate": 0.85, "multiCollectionRate": 0.15, "maxCollectionsPerCipher": 3 }, + "userCollections": { "min": 1, "max": 20, "shape": "powerLaw", "skew": 0.5 } + }, + "ciphers": { "count": 15000 } +} diff --git a/util/Seeder/Seeds/fixtures/presets/scale/xl-highperm-weyland-yutani.json b/util/Seeder/Seeds/fixtures/presets/scale/xl-highperm-weyland-yutani.json new file mode 100644 index 000000000000..31ffed93ca96 --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/scale/xl-highperm-weyland-yutani.json @@ -0,0 +1,23 @@ +{ + "$schema": "../../../schemas/preset.schema.json", + "organization": { + "name": "Weyland-Yutani", + "domain": "weylandyutani.example", + "planType": "enterprise-annually", + "seats": 6000 + }, + "users": { "count": 5000, "realisticStatusMix": true }, + "groups": { "count": 500 }, + "collections": { "count": 1200 }, + "density": { + "membership": { "shape": "powerLaw", "skew": 0.8 }, + "collectionFanOut": { "min": 1, "max": 3, "shape": "powerLaw", "emptyGroupRate": 0.3 }, + "directAccessRatio": 0.4, + "permissions": { "readWrite": 0.55, "readOnly": 0.25, "manage": 0.10, "hidePasswords": 0.10 }, + "cipherAssignment": { "skew": "heavyRight", "orphanRate": 0.1, "multiCollectionRate": 0.30, "maxCollectionsPerCipher": 5 }, + "userCollections": { "min": 1, "max": 50, "shape": "powerLaw", "skew": 0.8 }, + "personalCiphers": { "shape": "lightUsage" }, + "folders": { "shape": "minimal" } + }, + "ciphers": { "count": 15000 } +} diff --git a/util/Seeder/Seeds/fixtures/presets/scale/xs-central-perk.json b/util/Seeder/Seeds/fixtures/presets/scale/xs-central-perk.json new file mode 100644 index 000000000000..3aaacbcaa9e7 --- /dev/null +++ b/util/Seeder/Seeds/fixtures/presets/scale/xs-central-perk.json @@ -0,0 +1,21 @@ +{ + "$schema": "../../../schemas/preset.schema.json", + "organization": { + "name": "Central Perk", + "domain": "centralperk.example", + "planType": "families-annually", + "seats": 6 + }, + "users": { "count": 6 }, + "groups": { "count": 2 }, + "collections": { "count": 10 }, + "density": { + "membership": { "shape": "uniform" }, + "collectionFanOut": { "min": 1, "max": 2, "shape": "uniform" }, + "directAccessRatio": 0.8, + "permissions": { "manage": 0.50, "readWrite": 0.40, "readOnly": 0.10 }, + "cipherAssignment": { "skew": "uniform", "orphanRate": 0.0, "multiCollectionRate": 0.20, "maxCollectionsPerCipher": 2 }, + "userCollections": { "min": 1, "max": 3, "shape": "uniform" } + }, + "ciphers": { "count": 200 } +} From 642bdf00363582d3c0e3b17a3d222c41e4b0ea04 Mon Sep 17 00:00:00 2001 From: voommen-livefront Date: Wed, 11 Mar 2026 11:34:46 -0500 Subject: [PATCH 65/85] PM-31923 updated property names for metrics --- .../Dirt/Models/Request/AddOrganizationReportRequestModel.cs | 4 +++- .../Request/UpdateOrganizationReportSummaryRequestModel.cs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Api/Dirt/Models/Request/AddOrganizationReportRequestModel.cs b/src/Api/Dirt/Models/Request/AddOrganizationReportRequestModel.cs index 24bdcade015d..34b6e3202aae 100644 --- a/src/Api/Dirt/Models/Request/AddOrganizationReportRequestModel.cs +++ b/src/Api/Dirt/Models/Request/AddOrganizationReportRequestModel.cs @@ -1,4 +1,5 @@ -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using System.Text.Json.Serialization; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; namespace Bit.Api.Dirt.Models.Request; @@ -8,6 +9,7 @@ public class AddOrganizationReportRequestModel public string? ContentEncryptionKey { get; set; } public string? SummaryData { get; set; } public string? ApplicationData { get; set; } + [JsonPropertyName("metrics")] public OrganizationReportMetrics? ReportMetrics { get; set; } public long? FileSize { get; set; } diff --git a/src/Api/Dirt/Models/Request/UpdateOrganizationReportSummaryRequestModel.cs b/src/Api/Dirt/Models/Request/UpdateOrganizationReportSummaryRequestModel.cs index 136213a2a3cf..15c7f91eb823 100644 --- a/src/Api/Dirt/Models/Request/UpdateOrganizationReportSummaryRequestModel.cs +++ b/src/Api/Dirt/Models/Request/UpdateOrganizationReportSummaryRequestModel.cs @@ -1,10 +1,12 @@ -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using System.Text.Json.Serialization; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; namespace Bit.Api.Dirt.Models.Request; public class UpdateOrganizationReportSummaryRequestModel { public string? SummaryData { get; set; } + [JsonPropertyName("metrics")] public OrganizationReportMetrics? ReportMetrics { get; set; } public UpdateOrganizationReportSummaryRequest ToData(Guid organizationId, Guid reportId) From 829e2e1e7d57e649c5f993312d010b56f248d7ec Mon Sep 17 00:00:00 2001 From: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:42:27 -0400 Subject: [PATCH 66/85] Restrict users from sending altered project name/value and it being saved to the database as an invalid encrypted value. (#6853) --- src/Api/Vault/Models/CipherAttachmentModel.cs | 2 + src/Api/Vault/Models/CipherFieldModel.cs | 2 + .../Vault/Models/CipherFieldModelTests.cs | 105 ++++++++++++++++++ .../EncryptedStringAttributeTests.cs | 3 + 4 files changed, 112 insertions(+) create mode 100644 test/Api.Test/Vault/Models/CipherFieldModelTests.cs diff --git a/src/Api/Vault/Models/CipherAttachmentModel.cs b/src/Api/Vault/Models/CipherAttachmentModel.cs index 381f66d37d91..b5165f712529 100644 --- a/src/Api/Vault/Models/CipherAttachmentModel.cs +++ b/src/Api/Vault/Models/CipherAttachmentModel.cs @@ -16,8 +16,10 @@ public CipherAttachmentModel(CipherAttachment.MetaData data) Key = data.Key; } + [EncryptedString] [EncryptedStringLength(1000)] public string FileName { get; set; } + [EncryptedString] [EncryptedStringLength(1000)] public string Key { get; set; } } diff --git a/src/Api/Vault/Models/CipherFieldModel.cs b/src/Api/Vault/Models/CipherFieldModel.cs index 93abf9f64721..a694ea9c6646 100644 --- a/src/Api/Vault/Models/CipherFieldModel.cs +++ b/src/Api/Vault/Models/CipherFieldModel.cs @@ -20,8 +20,10 @@ public CipherFieldModel(CipherFieldData data) } public FieldType Type { get; set; } + [EncryptedString] [EncryptedStringLength(1000)] public string Name { get; set; } + [EncryptedString] [EncryptedStringLength(5000)] public string Value { get; set; } public int? LinkedId { get; set; } diff --git a/test/Api.Test/Vault/Models/CipherFieldModelTests.cs b/test/Api.Test/Vault/Models/CipherFieldModelTests.cs new file mode 100644 index 000000000000..e6f395d633dd --- /dev/null +++ b/test/Api.Test/Vault/Models/CipherFieldModelTests.cs @@ -0,0 +1,105 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Vault.Models; +using Bit.Core.Vault.Enums; +using Xunit; + +namespace Bit.Api.Test.Vault.Models; + +public class CipherFieldModelTests +{ + /// + /// Tests that plain text in the Name field is rejected by validation. + /// This is a regression test for the DoS vulnerability where a user could + /// submit plain text instead of encrypted data, causing decryption failures + /// that broke the vault for all organization members. + /// + [Theory] + [InlineData("Test")] // Plain text - should be rejected + [InlineData("Hello World")] // Plain text - should be rejected + [InlineData("")] // Empty string - should be rejected + [InlineData("not-encrypted-at-all")] // Plain text - should be rejected + [InlineData("invalid|format")] // Invalid format - should be rejected + public void Validate_PlainTextName_ReturnsValidationError(string plainTextName) + { + var model = new CipherFieldModel + { + Type = FieldType.Text, + Name = plainTextName, + Value = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==" // Valid encrypted value + }; + + var validationResults = new List(); + var validationContext = new ValidationContext(model); + var isValid = Validator.TryValidateObject(model, validationContext, validationResults, validateAllProperties: true); + + Assert.False(isValid, $"Plain text '{plainTextName}' should have been rejected by validation"); + Assert.Contains(validationResults, r => r.MemberNames.Contains(nameof(CipherFieldModel.Name))); + } + + /// + /// Tests that plain text in the Value field is rejected by validation. + /// + [Theory] + [InlineData("Test")] // Plain text - should be rejected + [InlineData("SecretPassword123")] // Plain text - should be rejected + [InlineData("")] // Empty string - should be rejected + public void Validate_PlainTextValue_ReturnsValidationError(string plainTextValue) + { + var model = new CipherFieldModel + { + Type = FieldType.Text, + Name = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // Valid encrypted name + Value = plainTextValue + }; + + var validationResults = new List(); + var validationContext = new ValidationContext(model); + var isValid = Validator.TryValidateObject(model, validationContext, validationResults, validateAllProperties: true); + + Assert.False(isValid, $"Plain text value '{plainTextValue}' should have been rejected by validation"); + Assert.Contains(validationResults, r => r.MemberNames.Contains(nameof(CipherFieldModel.Value))); + } + + /// + /// Tests that properly encrypted strings in Name and Value pass validation. + /// + [Theory] + [InlineData("2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // AesCbc256_HmacSha256_B64 + [InlineData("0.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==")] // AesCbc256_B64 + [InlineData("aXY=|Y3Q=|cnNhQ3Q=")] // Legacy format without header + public void Validate_EncryptedStrings_PassesValidation(string encryptedString) + { + var model = new CipherFieldModel + { + Type = FieldType.Text, + Name = encryptedString, + Value = encryptedString + }; + + var validationResults = new List(); + var validationContext = new ValidationContext(model); + var isValid = Validator.TryValidateObject(model, validationContext, validationResults, validateAllProperties: true); + + Assert.True(isValid, $"Encrypted string '{encryptedString}' should have passed validation. Errors: {string.Join(", ", validationResults.Select(r => r.ErrorMessage))}"); + } + + /// + /// Tests that null values are allowed (fields are optional). + /// + [Fact] + public void Validate_NullNameAndValue_PassesValidation() + { + var model = new CipherFieldModel + { + Type = FieldType.Text, + Name = null, + Value = null + }; + + var validationResults = new List(); + var validationContext = new ValidationContext(model); + var isValid = Validator.TryValidateObject(model, validationContext, validationResults, validateAllProperties: true); + + Assert.True(isValid, $"Null values should be allowed. Errors: {string.Join(", ", validationResults.Select(r => r.ErrorMessage))}"); + } +} diff --git a/test/Core.Test/Utilities/EncryptedStringAttributeTests.cs b/test/Core.Test/Utilities/EncryptedStringAttributeTests.cs index b5989987fb70..eeccb3be3f3f 100644 --- a/test/Core.Test/Utilities/EncryptedStringAttributeTests.cs +++ b/test/Core.Test/Utilities/EncryptedStringAttributeTests.cs @@ -35,6 +35,9 @@ public void IsValid_ReturnsTrue_WhenValid(string? input) } [Theory] + [InlineData("Test")] // Plain text injection attack - DoS vulnerability regression test + [InlineData("Hello World")] // Plain text injection attack + [InlineData("SecretPassword123")] // Plain text injection attack [InlineData("")] // Empty string [InlineData(".")] // Split Character but two empty parts [InlineData("|")] // One encrypted part split character but empty parts From fec7ac9578026eef6b90cf69b7f0e3f1ebc3f09f Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:12:45 -0400 Subject: [PATCH 67/85] chore(flags): Remove obsolete client flags --- src/Core/Constants.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index c51a1b2ca5da..354ac158cdb9 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -151,10 +151,6 @@ public static class FeatureFlagKeys public const string DesktopMigrationMilestone4 = "desktop-ui-migration-milestone-4"; /* Auth Team */ - public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; - public const string BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals"; - 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 DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; public const string PM2035PasskeyUnlock = "pm-2035-passkey-unlock"; From 6aa01125b563a3054cae22f35594b6be003cec62 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Thu, 12 Mar 2026 15:04:07 +0100 Subject: [PATCH 68/85] Add density profiles to Seeder CLI (#7205) --- .../Data/Distributions/DensityProfiles.cs | 216 +++++++++ util/Seeder/Data/Enums/CompanyCategory.cs | 41 +- util/Seeder/Data/Enums/OrgStructureModel.cs | 2 +- util/Seeder/Data/Static/Companies.cs | 449 ++++++++++++++++-- util/Seeder/Data/Static/OrgStructures.cs | 99 +++- .../Options/OrganizationVaultOptions.cs | 6 + util/Seeder/Pipeline/RecipeOrchestrator.cs | 4 + .../Commands/OrganizationArgs.cs | 25 +- 8 files changed, 778 insertions(+), 64 deletions(-) create mode 100644 util/Seeder/Data/Distributions/DensityProfiles.cs diff --git a/util/Seeder/Data/Distributions/DensityProfiles.cs b/util/Seeder/Data/Distributions/DensityProfiles.cs new file mode 100644 index 000000000000..d83432bd958f --- /dev/null +++ b/util/Seeder/Data/Distributions/DensityProfiles.cs @@ -0,0 +1,216 @@ +using Bit.Seeder.Data.Enums; +using Bit.Seeder.Options; + +namespace Bit.Seeder.Data.Distributions; + +/// +/// Named density profiles for CLI usage. Size-independent — user controls entity counts +/// via -u, -g, -c, -l flags separately. +/// +public static class DensityProfiles +{ + /// + /// Balanced mid-market org. Even split between direct and group-mediated access. + /// Archetype: Sterling Cooper / Wayne Enterprises. + /// + public static DensityProfile Balanced { get; } = new() + { + MembershipShape = MembershipDistributionShape.PowerLaw, + MembershipSkew = 0.6, + CollectionFanOutMin = 1, + CollectionFanOutMax = 5, + FanOutShape = CollectionFanOutShape.PowerLaw, + EmptyGroupRate = 0.1, + DirectAccessRatio = 0.5, + PermissionDistribution = PermissionDistributions.MidMarket, + UserCollectionMin = 1, + UserCollectionMax = 10, + UserCollectionShape = CollectionFanOutShape.PowerLaw, + UserCollectionSkew = 0.5, + CipherSkew = CipherCollectionSkew.HeavyRight, + OrphanCipherRate = 0.08, + MultiCollectionRate = 0.20, + MaxCollectionsPerCipher = 3, + PersonalCipherDistribution = PersonalCipherDistributions.Realistic, + FolderDistribution = FolderCountDistributions.Realistic, + }; + + /// + /// High permission density with steep power-law membership and enterprise read-heavy permissions. + /// Archetype: Tyrell Corp / Bluth Company. + /// + public static DensityProfile HighPerm { get; } = new() + { + MembershipShape = MembershipDistributionShape.PowerLaw, + MembershipSkew = 0.8, + CollectionFanOutMin = 2, + CollectionFanOutMax = 8, + FanOutShape = CollectionFanOutShape.PowerLaw, + EmptyGroupRate = 0.2, + DirectAccessRatio = 0.6, + PermissionDistribution = PermissionDistributions.Enterprise, + UserCollectionMin = 1, + UserCollectionMax = 30, + UserCollectionShape = CollectionFanOutShape.PowerLaw, + UserCollectionSkew = 0.7, + CipherSkew = CipherCollectionSkew.HeavyRight, + OrphanCipherRate = 0.15, + MultiCollectionRate = 0.30, + MaxCollectionsPerCipher = 4, + PersonalCipherDistribution = PersonalCipherDistributions.Realistic, + FolderDistribution = FolderCountDistributions.Enterprise, + }; + + /// + /// Mega-group with high collection count and write-heavy permissions. + /// Archetype: Umbrella Corp. + /// + public static DensityProfile HighCollection { get; } = new() + { + MembershipShape = MembershipDistributionShape.MegaGroup, + MembershipSkew = 0.5, + CollectionFanOutMin = 1, + CollectionFanOutMax = 3, + FanOutShape = CollectionFanOutShape.FrontLoaded, + EmptyGroupRate = 0.0, + DirectAccessRatio = 0.9, + PermissionDistribution = PermissionDistributions.MidMarketWriteHeavy, + UserCollectionMin = 1, + UserCollectionMax = 15, + UserCollectionShape = CollectionFanOutShape.PowerLaw, + UserCollectionSkew = 0.6, + CipherSkew = CipherCollectionSkew.HeavyRight, + OrphanCipherRate = 0.20, + MultiCollectionRate = 0.25, + MaxCollectionsPerCipher = 3, + PersonalCipherDistribution = PersonalCipherDistributions.Realistic, + FolderDistribution = FolderCountDistributions.Realistic, + }; + + /// + /// Extreme mega-group with all-direct access and very high orphan rate. + /// Archetype: Initech (Baker McKenzie production pattern). + /// + public static DensityProfile Broad { get; } = new() + { + MembershipShape = MembershipDistributionShape.MegaGroup, + MembershipSkew = 0.95, + CollectionFanOutMin = 1, + CollectionFanOutMax = 2, + FanOutShape = CollectionFanOutShape.Uniform, + EmptyGroupRate = 0.0, + DirectAccessRatio = 1.0, + PermissionDistribution = PermissionDistributions.EnterpriseManageHeavy, + UserCollectionMin = 1, + UserCollectionMax = 20, + UserCollectionShape = CollectionFanOutShape.PowerLaw, + UserCollectionSkew = 0.5, + CipherSkew = CipherCollectionSkew.HeavyRight, + OrphanCipherRate = 0.85, + MultiCollectionRate = 0.15, + MaxCollectionsPerCipher = 3, + PersonalCipherDistribution = PersonalCipherDistributions.LightUsage, + FolderDistribution = FolderCountDistributions.Minimal, + }; + + /// + /// Low-complexity family/starter org with uniform distributions and no orphans. + /// Archetype: Central Perk. + /// + public static DensityProfile Minimal { get; } = new() + { + MembershipShape = MembershipDistributionShape.Uniform, + MembershipSkew = 0.0, + CollectionFanOutMin = 1, + CollectionFanOutMax = 2, + FanOutShape = CollectionFanOutShape.Uniform, + EmptyGroupRate = 0.0, + DirectAccessRatio = 0.8, + PermissionDistribution = PermissionDistributions.Family, + UserCollectionMin = 1, + UserCollectionMax = 3, + UserCollectionShape = CollectionFanOutShape.Uniform, + UserCollectionSkew = 0.0, + CipherSkew = CipherCollectionSkew.Uniform, + OrphanCipherRate = 0.0, + MultiCollectionRate = 0.20, + MaxCollectionsPerCipher = 2, + PersonalCipherDistribution = PersonalCipherDistributions.Realistic, + FolderDistribution = FolderCountDistributions.Realistic, + }; + + /// + /// Almost all access via groups, very low direct access. Tests CollectionGroup-heavy code paths. + /// + public static DensityProfile GroupHeavy { get; } = new() + { + MembershipShape = MembershipDistributionShape.PowerLaw, + MembershipSkew = 0.7, + CollectionFanOutMin = 2, + CollectionFanOutMax = 6, + FanOutShape = CollectionFanOutShape.PowerLaw, + EmptyGroupRate = 0.1, + DirectAccessRatio = 0.1, + PermissionDistribution = PermissionDistributions.MidMarketWriteHeavy, + UserCollectionMin = 1, + UserCollectionMax = 8, + UserCollectionShape = CollectionFanOutShape.PowerLaw, + UserCollectionSkew = 0.5, + CipherSkew = CipherCollectionSkew.HeavyRight, + OrphanCipherRate = 0.10, + MultiCollectionRate = 0.20, + MaxCollectionsPerCipher = 3, + PersonalCipherDistribution = PersonalCipherDistributions.Realistic, + FolderDistribution = FolderCountDistributions.Realistic, + }; + + /// + /// Low access density — few groups per collection, few collections per user, high orphan rate. + /// Models orgs where most users have minimal access and most ciphers are unassigned. + /// + public static DensityProfile Sparse { get; } = new() + { + MembershipShape = MembershipDistributionShape.PowerLaw, + MembershipSkew = 0.5, + CollectionFanOutMin = 1, + CollectionFanOutMax = 2, + FanOutShape = CollectionFanOutShape.Uniform, + EmptyGroupRate = 0.3, + DirectAccessRatio = 0.3, + PermissionDistribution = PermissionDistributions.Enterprise, + UserCollectionMin = 1, + UserCollectionMax = 3, + UserCollectionShape = CollectionFanOutShape.Uniform, + UserCollectionSkew = 0.0, + CipherSkew = CipherCollectionSkew.HeavyRight, + OrphanCipherRate = 0.30, + MultiCollectionRate = 0.10, + MaxCollectionsPerCipher = 2, + PersonalCipherDistribution = PersonalCipherDistributions.LightUsage, + FolderDistribution = FolderCountDistributions.Minimal, + }; + + /// + /// Parses a profile name to a . Returns null for null/empty input. + /// + public static DensityProfile? Parse(string? name) + { + if (string.IsNullOrEmpty(name)) + { + return null; + } + + return name.ToLowerInvariant() switch + { + "balanced" => Balanced, + "highperm" => HighPerm, + "highcollection" => HighCollection, + "broad" => Broad, + "minimal" => Minimal, + "groupheavy" => GroupHeavy, + "sparse" => Sparse, + _ => throw new ArgumentException( + $"Unknown density profile '{name}'. Use: balanced, highPerm, highCollection, broad, minimal, groupHeavy, or sparse") + }; + } +} diff --git a/util/Seeder/Data/Enums/CompanyCategory.cs b/util/Seeder/Data/Enums/CompanyCategory.cs index cee7e0c58373..0d7015486d68 100644 --- a/util/Seeder/Data/Enums/CompanyCategory.cs +++ b/util/Seeder/Data/Enums/CompanyCategory.cs @@ -5,7 +5,42 @@ /// public enum CompanyCategory { - SocialMedia, Streaming, ECommerce, CRM, Security, CloudInfrastructure, - DevOps, Collaboration, HRTalent, FinanceERP, Analytics, ProjectManagement, - Marketing, ITServiceManagement, Productivity, Developer, Financial + SocialMedia, + Streaming, + ECommerce, + CRM, + Security, + CloudInfrastructure, + DevOps, + Collaboration, + HRTalent, + FinanceERP, + Analytics, + ProjectManagement, + Marketing, + ITServiceManagement, + Productivity, + Developer, + Financial, + Travel, + Airlines, + Hotels, + CarRental, + Rail, + RideShare, + Banking, + Insurance, + Healthcare, + Telecom, + Education, + Retail, + FoodBeverage, + Automotive, + Gaming, + News, + Energy, + RealEstate, + Logistics, + Fitness, + Government } diff --git a/util/Seeder/Data/Enums/OrgStructureModel.cs b/util/Seeder/Data/Enums/OrgStructureModel.cs index 675d0e758f87..77bb36bcb2b5 100644 --- a/util/Seeder/Data/Enums/OrgStructureModel.cs +++ b/util/Seeder/Data/Enums/OrgStructureModel.cs @@ -3,4 +3,4 @@ /// /// Organizational structure model types. /// -public enum OrgStructureModel { Traditional, Spotify, Modern } +public enum OrgStructureModel { Traditional, Spotify, Modern, Government, SchoolDistrict, Healthcare, Startup } diff --git a/util/Seeder/Data/Static/Companies.cs b/util/Seeder/Data/Static/Companies.cs index 69e023217d7a..585f17793bf1 100644 --- a/util/Seeder/Data/Static/Companies.cs +++ b/util/Seeder/Data/Static/Companies.cs @@ -17,86 +17,433 @@ internal static class Companies internal static readonly Company[] NorthAmerica = [ // CRM & Sales - new("salesforce.com", "Salesforce", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.NorthAmerica), - new("hubspot.com", "HubSpot", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("salesforce.example", "Salesforce", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("hubspot.example", "HubSpot", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.NorthAmerica), // Security - new("crowdstrike.com", "CrowdStrike", CompanyCategory.Security, CompanyType.Enterprise, GeographicRegion.NorthAmerica), - new("okta.com", "Okta", CompanyCategory.Security, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("crowdstrike.example", "CrowdStrike", CompanyCategory.Security, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("okta.example", "Okta", CompanyCategory.Security, CompanyType.Enterprise, GeographicRegion.NorthAmerica), // Observability & DevOps - new("datadog.com", "Datadog", CompanyCategory.DevOps, CompanyType.Enterprise, GeographicRegion.NorthAmerica), - new("splunk.com", "Splunk", CompanyCategory.Analytics, CompanyType.Enterprise, GeographicRegion.NorthAmerica), - new("pagerduty.com", "PagerDuty", CompanyCategory.ITServiceManagement, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("datadog.example", "Datadog", CompanyCategory.DevOps, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("splunk.example", "Splunk", CompanyCategory.Analytics, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("pagerduty.example", "PagerDuty", CompanyCategory.ITServiceManagement, CompanyType.Enterprise, GeographicRegion.NorthAmerica), // Cloud & Infrastructure - new("snowflake.com", "Snowflake", CompanyCategory.CloudInfrastructure, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("snowflake.example", "Snowflake", CompanyCategory.CloudInfrastructure, CompanyType.Enterprise, GeographicRegion.NorthAmerica), // HR & Workforce - new("workday.com", "Workday", CompanyCategory.HRTalent, CompanyType.Enterprise, GeographicRegion.NorthAmerica), - new("servicenow.com", "ServiceNow", CompanyCategory.ITServiceManagement, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("workday.example", "Workday", CompanyCategory.HRTalent, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("servicenow.example", "ServiceNow", CompanyCategory.ITServiceManagement, CompanyType.Enterprise, GeographicRegion.NorthAmerica), // Consumer Tech Giants - new("google.com", "Google", CompanyCategory.Productivity, CompanyType.Hybrid, GeographicRegion.NorthAmerica), - new("meta.com", "Meta", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.NorthAmerica), - new("amazon.com", "Amazon", CompanyCategory.ECommerce, CompanyType.Hybrid, GeographicRegion.NorthAmerica), - new("netflix.com", "Netflix", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("google.example", "Google", CompanyCategory.Productivity, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + new("meta.example", "Meta", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("amazon.example", "Amazon", CompanyCategory.ECommerce, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + new("netflix.example", "Netflix", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), // Developer Tools - new("github.com", "GitHub", CompanyCategory.Developer, CompanyType.Hybrid, GeographicRegion.NorthAmerica), - new("stripe.com", "Stripe", CompanyCategory.Financial, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("github.example", "GitHub", CompanyCategory.Developer, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + new("stripe.example", "Stripe", CompanyCategory.Financial, CompanyType.Enterprise, GeographicRegion.NorthAmerica), // Collaboration - new("slack.com", "Slack", CompanyCategory.Collaboration, CompanyType.Enterprise, GeographicRegion.NorthAmerica), - new("zoom.us", "Zoom", CompanyCategory.Collaboration, CompanyType.Hybrid, GeographicRegion.NorthAmerica), - new("dropbox.com", "Dropbox", CompanyCategory.Productivity, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + new("slack.example", "Slack", CompanyCategory.Collaboration, CompanyType.Enterprise, GeographicRegion.NorthAmerica), + new("zoom.example", "Zoom", CompanyCategory.Collaboration, CompanyType.Hybrid, GeographicRegion.NorthAmerica), + new("dropbox.example", "Dropbox", CompanyCategory.Productivity, CompanyType.Hybrid, GeographicRegion.NorthAmerica), // Streaming - new("spotify.com", "Spotify", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica) + new("hulu.example", "Hulu", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("max.example", "Max", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("paramountplus.example", "Paramount+", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("peacocktv.example", "Peacock", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("tubi.example", "Tubi", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("pluto.example", "Pluto TV", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("sling.example", "Sling TV", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("fubo.example", "Fubo", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("pandora.example", "Pandora", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("iheart.example", "iHeart", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("crunchyroll.example", "Crunchyroll", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.NorthAmerica), + new("vimeo.example", "Vimeo", CompanyCategory.Streaming, CompanyType.Hybrid, GeographicRegion.NorthAmerica) ]; internal static readonly Company[] Europe = [ // Enterprise Software - new("sap.com", "SAP", CompanyCategory.FinanceERP, CompanyType.Enterprise, GeographicRegion.Europe), - new("elastic.co", "Elastic", CompanyCategory.Analytics, CompanyType.Enterprise, GeographicRegion.Europe), - new("atlassian.com", "Atlassian", CompanyCategory.ProjectManagement, CompanyType.Enterprise, GeographicRegion.Europe), + new("sap.example", "SAP", CompanyCategory.FinanceERP, CompanyType.Enterprise, GeographicRegion.Europe), + new("elastic.example", "Elastic", CompanyCategory.Analytics, CompanyType.Enterprise, GeographicRegion.Europe), + // Streaming + new("spotify.example", "Spotify", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.Europe), // Fintech - new("wise.com", "Wise", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), - new("revolut.com", "Revolut", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), - new("klarna.com", "Klarna", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), - new("n26.com", "N26", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), + new("wise.example", "Wise", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), + new("revolut.example", "Revolut", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), + new("klarna.example", "Klarna", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), + new("n26.example", "N26", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Europe), // Developer Tools - new("gitlab.com", "GitLab", CompanyCategory.DevOps, CompanyType.Enterprise, GeographicRegion.Europe), - new("contentful.com", "Contentful", CompanyCategory.Developer, CompanyType.Enterprise, GeographicRegion.Europe), + new("gitlab.example", "GitLab", CompanyCategory.DevOps, CompanyType.Enterprise, GeographicRegion.Europe), + new("contentful.example", "Contentful", CompanyCategory.Developer, CompanyType.Enterprise, GeographicRegion.Europe), // Consumer Services - new("deliveroo.com", "Deliveroo", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.Europe), - new("booking.com", "Booking.com", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.Europe), + new("deliveroo.example", "Deliveroo", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.Europe), + new("booking.example", "Booking.com", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.Europe), // Collaboration - new("miro.com", "Miro", CompanyCategory.Collaboration, CompanyType.Enterprise, GeographicRegion.Europe), - new("intercom.io", "Intercom", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.Europe), + new("miro.example", "Miro", CompanyCategory.Collaboration, CompanyType.Enterprise, GeographicRegion.Europe), + new("intercom.example", "Intercom", CompanyCategory.CRM, CompanyType.Enterprise, GeographicRegion.Europe), // Business Software - new("sage.com", "Sage", CompanyCategory.FinanceERP, CompanyType.Enterprise, GeographicRegion.Europe), - new("adyen.com", "Adyen", CompanyCategory.Financial, CompanyType.Enterprise, GeographicRegion.Europe) + new("sage.example", "Sage", CompanyCategory.FinanceERP, CompanyType.Enterprise, GeographicRegion.Europe), + new("adyen.example", "Adyen", CompanyCategory.Financial, CompanyType.Enterprise, GeographicRegion.Europe) ]; internal static readonly Company[] AsiaPacific = [ // Chinese Tech Giants - new("alibaba.com", "Alibaba", CompanyCategory.ECommerce, CompanyType.Hybrid, GeographicRegion.AsiaPacific), - new("tencent.com", "Tencent", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), - new("bytedance.com", "ByteDance", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), - new("wechat.com", "WeChat", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("alibaba.example", "Alibaba", CompanyCategory.ECommerce, CompanyType.Hybrid, GeographicRegion.AsiaPacific), + new("tencent.example", "Tencent", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("bytedance.example", "ByteDance", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("baidu.example", "Baidu", CompanyCategory.Productivity, CompanyType.Hybrid, GeographicRegion.AsiaPacific), // Japanese Companies - new("rakuten.com", "Rakuten", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), - new("line.me", "Line", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), - new("sony.com", "Sony", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.AsiaPacific), - new("paypay.ne.jp", "PayPay", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("rakuten.example", "Rakuten", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("line.example", "Line", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("sony.example", "Sony", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("paypay.example", "PayPay", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.AsiaPacific), // Korean Companies - new("samsung.com", "Samsung", CompanyCategory.Productivity, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("samsung.example", "Samsung", CompanyCategory.Productivity, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Australian Companies + new("atlassian.example", "Atlassian", CompanyCategory.ProjectManagement, CompanyType.Enterprise, GeographicRegion.AsiaPacific), // Southeast Asian Companies - new("grab.com", "Grab", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), - new("sea.com", "Sea Limited", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), - new("shopee.com", "Shopee", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), - new("lazada.com", "Lazada", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), - new("gojek.com", "Gojek", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("grab.example", "Grab", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("sea.example", "Sea Limited", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("coupang.example", "Coupang", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("lazada.example", "Lazada", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("gojek.example", "Gojek", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), // Indian Companies - new("flipkart.com", "Flipkart", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific) + new("flipkart.example", "Flipkart", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Banking + new("dbs.example", "DBS", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("sbi.example", "SBI", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("icicibank.example", "ICICI Bank", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("mufg.example", "MUFG", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("maybank.example", "Maybank", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("cba.example", "Commonwealth Bank", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("ocbc.example", "OCBC", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("kotak.example", "Kotak Mahindra", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Airlines + new("singaporeair.example", "Singapore Airlines", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("cathaypacific.example", "Cathay Pacific", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("ana.example", "ANA", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("qantas.example", "Qantas", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("airasia.example", "AirAsia", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("koreanair.example", "Korean Air", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("jal.example", "Japan Airlines", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("indigo.example", "IndiGo", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Travel + new("makemytrip.example", "MakeMyTrip", CompanyCategory.Travel, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("agoda.example", "Agoda", CompanyCategory.Travel, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("traveloka.example", "Traveloka", CompanyCategory.Travel, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("trip.example", "Trip.com", CompanyCategory.Travel, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Hotels + new("oyo.example", "OYO", CompanyCategory.Hotels, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Car Rental + new("zoomcar.example", "Zoomcar", CompanyCategory.CarRental, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Rail + new("irctc.example", "IRCTC", CompanyCategory.Rail, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("jreast.example", "JR East", CompanyCategory.Rail, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("korail.example", "Korail", CompanyCategory.Rail, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Ride Share + new("ola.example", "Ola", CompanyCategory.RideShare, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("didiglobal.example", "DiDi", CompanyCategory.RideShare, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Insurance + new("pingan.example", "Ping An", CompanyCategory.Insurance, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("aia.example", "AIA", CompanyCategory.Insurance, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("tal.example", "TAL", CompanyCategory.Insurance, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Healthcare + new("practo.example", "Practo", CompanyCategory.Healthcare, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("1mg.example", "1mg", CompanyCategory.Healthcare, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("halodoc.example", "Halodoc", CompanyCategory.Healthcare, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Telecom + new("nttdocomo.example", "NTT Docomo", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("singtel.example", "Singtel", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("telstra.example", "Telstra", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("jio.example", "Jio", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("sktelecom.example", "SK Telecom", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("airtelindia.example", "Airtel India", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Education + new("byjus.example", "Byju's", CompanyCategory.Education, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("unacademy.example", "Unacademy", CompanyCategory.Education, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("u-tokyo.example", "University of Tokyo", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.AsiaPacific), + new("rmit.example", "RMIT University", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.AsiaPacific), + new("sutd.example", "Singapore University of Technology and Design", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.AsiaPacific), + new("iith.example", "IIT Hyderabad", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.AsiaPacific), + new("sdu.example", "Seoul Digital University", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.AsiaPacific), + // Retail + new("uniqlo.example", "Uniqlo", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("muji.example", "Muji", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("tokopedia.example", "Tokopedia", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("jd.example", "JD.com", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("myntra.example", "Myntra", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("bigbasket.example", "BigBasket", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Food & Beverage + new("zomato.example", "Zomato", CompanyCategory.FoodBeverage, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("swiggy.example", "Swiggy", CompanyCategory.FoodBeverage, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("foodpanda.example", "Foodpanda", CompanyCategory.FoodBeverage, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("meituan.example", "Meituan", CompanyCategory.FoodBeverage, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Automotive + new("toyota.example", "Toyota", CompanyCategory.Automotive, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("honda.example", "Honda", CompanyCategory.Automotive, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("hyundai.example", "Hyundai", CompanyCategory.Automotive, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("byd.example", "BYD", CompanyCategory.Automotive, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("suzuki.example", "Suzuki", CompanyCategory.Automotive, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Gaming + new("nintendo.example", "Nintendo", CompanyCategory.Gaming, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("bandainamco.example", "Bandai Namco", CompanyCategory.Gaming, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("hoyoverse.example", "HoYoverse", CompanyCategory.Gaming, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("krafton.example", "Krafton", CompanyCategory.Gaming, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("nexon.example", "Nexon", CompanyCategory.Gaming, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // News & Media + new("nikkei.example", "Nikkei", CompanyCategory.News, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("scmp.example", "South China Morning Post", CompanyCategory.News, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("timesofindia.example", "Times of India", CompanyCategory.News, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("straitstimes.example", "Straits Times", CompanyCategory.News, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Streaming + new("iqiyi.example", "iQIYI", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("hotstar.example", "Hotstar", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("bilibili.example", "Bilibili", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Social Media + new("weibo.example", "Weibo", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("kakaocorp.example", "KakaoTalk", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("naver.example", "Naver", CompanyCategory.SocialMedia, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Energy + new("tepco.example", "TEPCO", CompanyCategory.Energy, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("originenergy.example", "Origin Energy", CompanyCategory.Energy, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Logistics + new("kuronekoyamato.example", "Yamato Transport", CompanyCategory.Logistics, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("sf-express.example", "SF Express", CompanyCategory.Logistics, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("auspost.example", "Australia Post", CompanyCategory.Logistics, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Financial + new("phonepe.example", "PhonePe", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("paytm.example", "Paytm", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Fitness + new("cultfit.example", "Cult.fit", CompanyCategory.Fitness, CompanyType.Consumer, GeographicRegion.AsiaPacific), + // Government + new("mygovin.example", "MyGov India", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("mygovau.example", "myGov Australia", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("digitaljp.example", "Japan Digital Agency", CompanyCategory.Government, CompanyType.Enterprise, GeographicRegion.AsiaPacific), + new("smartnation.example", "Smart Nation Singapore", CompanyCategory.Government, CompanyType.Enterprise, GeographicRegion.AsiaPacific), + new("digilocker.example", "DigiLocker India", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("atoau.example", "Australian Taxation Office", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.AsiaPacific), + new("msitkr.example", "Ministry of Science & ICT Korea", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.AsiaPacific) + ]; + + internal static readonly Company[] LatinAmerica = + [ + // Banking + new("bancodobrasil.example", "Banco do Brasil", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("itau.example", "Itau", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("nubank.example", "Nubank", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("banorte.example", "Banorte", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("bbvamx.example", "BBVA Mexico", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("bancolombia.example", "Bancolombia", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("bradesco.example", "Bradesco", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("bcpperu.example", "BCP Peru", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // Airlines + new("latamairlines.example", "LATAM Airlines", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("avianca.example", "Avianca", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("aeromexico.example", "Aeromexico", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("copaair.example", "Copa Airlines", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("voegol.example", "Gol Airlines", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("voeazul.example", "Azul Airlines", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // Travel + new("despegar.example", "Despegar", CompanyCategory.Travel, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("decolar.example", "Decolar", CompanyCategory.Travel, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // ECommerce + new("mercadolibre.example", "MercadoLibre", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("americanas.example", "Americanas", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("magazineluiza.example", "Magazine Luiza", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("falabella.example", "Falabella", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("linio.example", "Linio", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // Telecom + new("claro.example", "Claro", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("vivo.example", "Vivo", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("telmex.example", "Telmex", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("entel.example", "Entel", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // Financial + new("mercadopago.example", "MercadoPago", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("picpay.example", "PicPay", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("uala.example", "Uala", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // Food & Beverage + new("ifood.example", "iFood", CompanyCategory.FoodBeverage, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("rappi.example", "Rappi", CompanyCategory.FoodBeverage, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("pedidosya.example", "PedidosYa", CompanyCategory.FoodBeverage, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // Ride Share + new("99app.example", "99", CompanyCategory.RideShare, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("beat.example", "Beat", CompanyCategory.RideShare, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // Streaming + new("globoplay.example", "Globoplay", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("blim.example", "Blim TV", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // Retail + new("liverpool.example", "Liverpool", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("coppel.example", "Coppel", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("casasbahia.example", "Casas Bahia", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // News & Media + new("globo.example", "Globo", CompanyCategory.News, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("eluniversal.example", "El Universal", CompanyCategory.News, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // Government + new("govbr.example", "GOV.BR", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("satmx.example", "SAT Mexico", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("serpro.example", "Servico Federal de Processamento", CompanyCategory.Government, CompanyType.Enterprise, GeographicRegion.LatinAmerica), + new("dgii.example", "Direccion General de Impuestos", CompanyCategory.Government, CompanyType.Enterprise, GeographicRegion.LatinAmerica), + new("anses.example", "ANSES Argentina", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // Insurance + new("sulamerica.example", "SulAmerica", CompanyCategory.Insurance, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("portoseguro.example", "Porto Seguro", CompanyCategory.Insurance, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // Healthcare + new("drogasil.example", "Drogasil", CompanyCategory.Healthcare, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("docplanner.example", "Doctoralia", CompanyCategory.Healthcare, CompanyType.Consumer, GeographicRegion.LatinAmerica), + // Education + new("platzi.example", "Platzi", CompanyCategory.Education, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("domestika.example", "Domestika", CompanyCategory.Education, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("usp.example", "Universidade de Sao Paulo", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.LatinAmerica), + new("tecmx.example", "Tecnologico de Monterrey", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.LatinAmerica), + new("uba.example", "Universidad de Buenos Aires", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.LatinAmerica), + // Energy + new("petrobras.example", "Petrobras", CompanyCategory.Energy, CompanyType.Consumer, GeographicRegion.LatinAmerica), + new("ecopetrol.example", "Ecopetrol", CompanyCategory.Energy, CompanyType.Consumer, GeographicRegion.LatinAmerica) + ]; + + internal static readonly Company[] MiddleEast = + [ + // Banking + new("emiratesnbd.example", "Emirates NBD", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("alrajhibank.example", "Al Rajhi Bank", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("qnb.example", "QNB", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("garanti.example", "Garanti BBVA", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("isbank.example", "Isbank", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("kfh.example", "Kuwait Finance House", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("mashreqbank.example", "Mashreq", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("bankfab.example", "First Abu Dhabi Bank", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.MiddleEast), + // Airlines + new("emirates.example", "Emirates", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("qatarairways.example", "Qatar Airways", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("etihad.example", "Etihad Airways", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("turkishairlines.example", "Turkish Airlines", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("saudia.example", "Saudia", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("flynas.example", "Flynas", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("pegasus.example", "Pegasus Airlines", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.MiddleEast), + // Travel + new("wego.example", "Wego", CompanyCategory.Travel, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("almosafer.example", "Almosafer", CompanyCategory.Travel, CompanyType.Consumer, GeographicRegion.MiddleEast), + // Hotels + new("rotana.example", "Rotana Hotels", CompanyCategory.Hotels, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("jumeirah.example", "Jumeirah", CompanyCategory.Hotels, CompanyType.Consumer, GeographicRegion.MiddleEast), + // Telecom + new("etisalat.example", "Etisalat", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("stc.example", "STC", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("turkcell.example", "Turkcell", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("zain.example", "Zain", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("du.example", "Du", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.MiddleEast), + // ECommerce + new("noon.example", "Noon", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("trendyol.example", "Trendyol", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("hepsiburada.example", "Hepsiburada", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("amazonae.example", "Amazon AE", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.MiddleEast), + // Food & Beverage + new("talabat.example", "Talabat", CompanyCategory.FoodBeverage, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("yemeksepeti.example", "Yemeksepeti", CompanyCategory.FoodBeverage, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("hungerstation.example", "HungerStation", CompanyCategory.FoodBeverage, CompanyType.Consumer, GeographicRegion.MiddleEast), + // Ride Share + new("careem.example", "Careem", CompanyCategory.RideShare, CompanyType.Consumer, GeographicRegion.MiddleEast), + // Financial + new("stcpay.example", "STC Pay", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("paytabs.example", "PayTabs", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.MiddleEast), + // News & Media + new("aljazeera.example", "Al Jazeera", CompanyCategory.News, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("hurriyet.example", "Hurriyet", CompanyCategory.News, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("arabnews.example", "Arab News", CompanyCategory.News, CompanyType.Consumer, GeographicRegion.MiddleEast), + // Real Estate + new("bayut.example", "Bayut", CompanyCategory.RealEstate, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("propertyfinder.example", "Property Finder", CompanyCategory.RealEstate, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("sahibinden.example", "Sahibinden", CompanyCategory.RealEstate, CompanyType.Consumer, GeographicRegion.MiddleEast), + // Government + new("absher.example", "Absher", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("edevlet.example", "e-Devlet", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("uaepass.example", "UAE Pass", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("mocsa.example", "Ministry of Commerce Saudi Arabia", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("hukoomi.example", "Hukoomi Qatar", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.MiddleEast), + // Education + new("aud.example", "American University in Dubai", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.MiddleEast), + new("ksu.example", "King Saud University", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.MiddleEast), + new("metu.example", "Middle East Technical University", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.MiddleEast), + // Insurance + new("tawuniya.example", "Tawuniya", CompanyCategory.Insurance, CompanyType.Consumer, GeographicRegion.MiddleEast), + // Retail + new("lcwaikiki.example", "LC Waikiki", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("extrastores.example", "Extra Stores", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.MiddleEast), + new("defacto.example", "DeFacto", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.MiddleEast) + ]; + + internal static readonly Company[] Africa = + [ + // Banking + new("standardbank.example", "Standard Bank", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.Africa), + new("fnb.example", "FNB", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.Africa), + new("accessbank.example", "Access Bank", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.Africa), + new("equitybank.example", "Equity Bank", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.Africa), + new("absa.example", "Absa", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.Africa), + new("gtbank.example", "GTBank", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.Africa), + new("nedbank.example", "Nedbank", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.Africa), + new("kcbgroup.example", "KCB Bank", CompanyCategory.Banking, CompanyType.Consumer, GeographicRegion.Africa), + // Telecom + new("mtn.example", "MTN", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.Africa), + new("safaricom.example", "Safaricom", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.Africa), + new("vodacom.example", "Vodacom", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.Africa), + new("airtelafrica.example", "Airtel Africa", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.Africa), + new("maroctelecom.example", "Maroc Telecom", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.Africa), + new("telkomsa.example", "Telkom SA", CompanyCategory.Telecom, CompanyType.Consumer, GeographicRegion.Africa), + // Financial + new("opay.example", "OPay", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Africa), + new("flutterwave.example", "Flutterwave", CompanyCategory.Financial, CompanyType.Enterprise, GeographicRegion.Africa), + new("paystack.example", "Paystack", CompanyCategory.Financial, CompanyType.Enterprise, GeographicRegion.Africa), + new("chippercash.example", "Chipper Cash", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Africa), + new("moniepoint.example", "Moniepoint", CompanyCategory.Financial, CompanyType.Consumer, GeographicRegion.Africa), + // Airlines + new("ethiopianairlines.example", "Ethiopian Airlines", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.Africa), + new("flysaa.example", "South African Airways", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.Africa), + new("kenya-airways.example", "Kenya Airways", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.Africa), + new("egyptair.example", "EgyptAir", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.Africa), + new("royalairmaroc.example", "Royal Air Maroc", CompanyCategory.Airlines, CompanyType.Consumer, GeographicRegion.Africa), + // ECommerce + new("jumia.example", "Jumia", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.Africa), + new("takealot.example", "Takealot", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.Africa), + new("konga.example", "Konga", CompanyCategory.ECommerce, CompanyType.Consumer, GeographicRegion.Africa), + // Food & Beverage + new("mrdfood.example", "Mr D Food", CompanyCategory.FoodBeverage, CompanyType.Consumer, GeographicRegion.Africa), + new("chowdeck.example", "Chowdeck", CompanyCategory.FoodBeverage, CompanyType.Consumer, GeographicRegion.Africa), + new("yassir.example", "Yassir", CompanyCategory.FoodBeverage, CompanyType.Consumer, GeographicRegion.Africa), + // Ride Share + new("indrive.example", "inDrive", CompanyCategory.RideShare, CompanyType.Consumer, GeographicRegion.Africa), + // News & Media + new("news24.example", "News24", CompanyCategory.News, CompanyType.Consumer, GeographicRegion.Africa), + new("nationafrica.example", "Nation Africa", CompanyCategory.News, CompanyType.Consumer, GeographicRegion.Africa), + new("dailymaverick.example", "Daily Maverick", CompanyCategory.News, CompanyType.Consumer, GeographicRegion.Africa), + // Streaming + new("showmax.example", "Showmax", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.Africa), + new("dstv.example", "DStv", CompanyCategory.Streaming, CompanyType.Consumer, GeographicRegion.Africa), + // Insurance + new("oldmutual.example", "Old Mutual", CompanyCategory.Insurance, CompanyType.Consumer, GeographicRegion.Africa), + new("discovery.example", "Discovery", CompanyCategory.Insurance, CompanyType.Consumer, GeographicRegion.Africa), + // Education + new("ulesson.example", "uLesson", CompanyCategory.Education, CompanyType.Consumer, GeographicRegion.Africa), + new("alxafrica.example", "ALX", CompanyCategory.Education, CompanyType.Consumer, GeographicRegion.Africa), + new("cctu.example", "Cape Coast Technical University", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.Africa), + new("uj.example", "University of Johannesburg", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.Africa), + new("uonbi.example", "University of Nairobi", CompanyCategory.Education, CompanyType.Enterprise, GeographicRegion.Africa), + // Government + new("sars.example", "SARS eFiling", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.Africa), + new("nimcng.example", "NIMC Nigeria", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.Africa), + new("ecitizen.example", "eCitizen Kenya", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.Africa), + new("dhaza.example", "Department of Home Affairs SA", CompanyCategory.Government, CompanyType.Enterprise, GeographicRegion.Africa), + new("irembo.example", "Irembo Rwanda", CompanyCategory.Government, CompanyType.Consumer, GeographicRegion.Africa), + // Retail + new("shoprite.example", "Shoprite", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.Africa), + new("woolworths.example", "Woolworths SA", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.Africa), + new("checkers.example", "Checkers", CompanyCategory.Retail, CompanyType.Consumer, GeographicRegion.Africa), + // Logistics + new("thecourierguys.example", "The Courier Guy", CompanyCategory.Logistics, CompanyType.Consumer, GeographicRegion.Africa), + // Energy + new("eskom.example", "Eskom", CompanyCategory.Energy, CompanyType.Consumer, GeographicRegion.Africa) ]; - internal static readonly Company[] All = [.. NorthAmerica, .. Europe, .. AsiaPacific]; + internal static readonly Company[] All = [.. NorthAmerica, .. Europe, .. AsiaPacific, .. LatinAmerica, .. MiddleEast, .. Africa]; internal static Company[] Filter( CompanyType? type = null, diff --git a/util/Seeder/Data/Static/OrgStructures.cs b/util/Seeder/Data/Static/OrgStructures.cs index 48e49cd11874..f6d44a4cf91b 100644 --- a/util/Seeder/Data/Static/OrgStructures.cs +++ b/util/Seeder/Data/Static/OrgStructures.cs @@ -29,23 +29,31 @@ internal static class OrgStructures internal static readonly OrgStructure Spotify = new(OrgStructureModel.Spotify, [ - // Tribes + // Tribes (product verticals) new("Payments Tribe", ["Checkout Squad", "Fraud Prevention Squad", "Billing Squad", "Payment Methods Squad"]), new("Growth Tribe", ["Acquisition Squad", "Activation Squad", "Retention Squad", "Monetization Squad"]), new("Platform Tribe", ["API Squad", "Infrastructure Squad", "Data Platform Squad", "Developer Tools Squad"]), new("Experience Tribe", ["Web App Squad", "Mobile Squad", "Desktop Squad", "Accessibility Squad"]), - // Chapters + new("Content Tribe", ["Catalog Squad", "Curation Squad", "Metadata Squad", "Licensing Squad"]), + new("Marketplace Tribe", ["Discovery Squad", "Recommendations Squad", "Ads Squad", "Creator Tools Squad"]), + new("Infrastructure Tribe", ["Cloud Squad", "Networking Squad", "Storage Squad", "Observability Squad"]), + new("Partner Tribe", ["Integrations Squad", "Partner API Squad", "Onboarding Squad", "Compliance Squad"]), + // Chapters (skill groups) new("Backend Chapter", ["Java Developers", "Go Developers", "Python Developers", "Database Specialists"]), new("Frontend Chapter", ["React Developers", "TypeScript Specialists", "Performance Engineers", "UI Engineers"]), new("QA Chapter", ["Test Automation", "Manual Testing", "Performance Testing", "Security Testing"]), new("Design Chapter", ["Product Designers", "UX Researchers", "Visual Designers", "Design Systems"]), new("Data Science Chapter", ["ML Engineers", "Data Analysts", "Data Engineers", "AI Researchers"]), - // Guilds + new("DevOps Chapter", ["CI/CD Engineers", "Release Engineers", "Site Reliability", "Infrastructure Automation"]), + new("Product Management Chapter", ["Product Owners", "Business Analysts", "Market Research", "Roadmap Strategy"]), + new("Analytics Chapter", ["Metrics Engineers", "A/B Testing", "Business Intelligence", "Data Governance"]), + // Guilds (communities of practice) new("Security Guild"), new("Innovation Guild"), new("Architecture Guild"), new("Accessibility Guild"), - new("Developer Experience Guild") + new("Developer Experience Guild"), + new("Reliability Guild") ]); internal static readonly OrgStructure Modern = new(OrgStructureModel.Modern, @@ -72,13 +80,92 @@ internal static class OrgStructures new("Quality", ["Testing Strategy", "Release Quality", "Production Health"]) ]); - internal static readonly OrgStructure[] All = [Traditional, Spotify, Modern]; + internal static readonly OrgStructure Government = new(OrgStructureModel.Government, + [ + new("Mayor's Office", ["Chief of Staff", "Policy Advisors", "Scheduling", "Constituent Services"]), + new("City Council", ["Council Members", "Legislative Aides", "Clerk of Council", "Public Comment"]), + new("Police Department", ["Patrol", "Investigations", "Community Policing", "Records", "Training"]), + new("Fire Department", ["Suppression", "EMS", "Prevention", "Training", "Hazmat"]), + new("Public Works", ["Roads", "Water", "Sewer", "Stormwater", "Fleet Maintenance"]), + new("Parks & Recreation", ["Maintenance", "Programming", "Aquatics", "Forestry", "Events"]), + new("Finance", ["Budget", "Accounting", "Payroll", "Purchasing", "Revenue"]), + new("Planning & Zoning", ["Current Planning", "Long Range", "Code Enforcement", "GIS", "Historic Preservation"]), + new("Human Services", ["Social Services", "Aging", "Veterans", "Homelessness", "Youth Programs"]), + new("Library", ["Circulation", "Reference", "Children's Services", "Digital Services", "Branches"]), + new("Information Technology", ["Infrastructure", "Applications", "Cybersecurity", "Help Desk", "GIS"]), + new("Legal", ["City Attorney", "Risk Management", "Contracts", "Litigation", "Ethics"]), + new("Clerk", ["Records Management", "Elections", "FOIA", "Licensing", "Archives"]), + new("Economic Development", ["Business Attraction", "Grants", "Tourism", "Redevelopment", "Small Business"]), + new("Housing", ["Inspections", "Code Enforcement", "Affordable Housing", "Community Development"]), + new("Transportation", ["Traffic Engineering", "Transit", "Bike & Pedestrian", "Parking", "Signal Operations"]), + new("Environmental Services", ["Solid Waste", "Recycling", "Sustainability", "Air Quality", "Watershed"]), + new("Public Health", ["Epidemiology", "Inspections", "Immunizations", "Emergency Preparedness", "Health Education"]) + ]); + + internal static readonly OrgStructure SchoolDistrict = new(OrgStructureModel.SchoolDistrict, + [ + // District Administration + new("Superintendent's Office", ["Deputy Superintendent", "Board Liaison", "Strategic Planning", "Communications"]), + new("Board of Education", ["Board Members", "Board Secretary", "Policy Committee", "Finance Committee"]), + new("Curriculum & Instruction", ["Literacy", "STEM", "Social Studies", "World Languages", "Assessment"]), + new("Student Services", ["Counseling", "School Psychology", "Social Work", "Health Services", "Section 504"]), + new("Finance & Operations", ["Budget", "Accounting", "Payroll", "Purchasing", "Grants Management"]), + new("Human Resources", ["Recruitment", "Certification", "Benefits", "Labor Relations", "Professional Development"]), + new("Technology", ["Infrastructure", "Student Systems", "Instructional Technology", "Help Desk", "Data Analytics"]), + new("Facilities", ["Maintenance", "Custodial", "Capital Projects", "Energy Management", "Safety"]), + new("Transportation", ["Routing", "Fleet Maintenance", "Driver Training", "Special Needs Transport"]), + new("Food Services", ["Menu Planning", "Kitchen Operations", "Nutrition", "Free & Reduced Lunch"]), + new("Special Education", ["IEP Coordination", "Related Services", "Behavioral Support", "Transition Services"]), + // Schools + new("Lincoln Elementary", ["Grade K-2", "Grade 3-5", "Specials", "Student Support", "Media Center"]), + new("Washington Elementary", ["Grade K-2", "Grade 3-5", "Specials", "Student Support", "Media Center"]), + new("Jefferson Middle School", ["English", "Math", "Science", "Social Studies", "Electives", "Guidance"]), + new("Roosevelt High School", ["English", "Math", "Science", "Social Studies", "CTE", "Athletics", "Guidance"]), + new("Kennedy High School", ["English", "Math", "Science", "Social Studies", "CTE", "Athletics", "Guidance"]) + ]); + + internal static readonly OrgStructure Healthcare = new(OrgStructureModel.Healthcare, + [ + new("Administration", ["Executive Suite", "Strategic Planning", "Quality Improvement", "Accreditation"]), + new("Medical Staff", ["Chief Medical Officer", "Physician Credentialing", "Medical Records", "Clinical Research"]), + new("Nursing", ["Nurse Managers", "Clinical Educators", "Staffing", "Infection Control", "Patient Safety"]), + new("Emergency Department", ["Triage", "Trauma", "Observation", "Fast Track", "Crisis Intervention"]), + new("Surgery", ["General Surgery", "Orthopedics", "Neurosurgery", "Pre-Op", "Post-Op", "Anesthesiology"]), + new("Internal Medicine", ["Hospitalists", "Pulmonology", "Gastroenterology", "Nephrology", "Endocrinology"]), + new("Pediatrics", ["General Pediatrics", "NICU", "Pediatric Surgery", "Child Life", "Adolescent Medicine"]), + new("Obstetrics & Gynecology", ["Labor & Delivery", "Maternal-Fetal Medicine", "Gynecologic Surgery", "Midwifery"]), + new("Cardiology", ["Interventional", "Electrophysiology", "Heart Failure", "Cardiac Rehab", "Cath Lab"]), + new("Oncology", ["Medical Oncology", "Radiation Therapy", "Surgical Oncology", "Infusion Center", "Palliative Care"]), + new("Radiology", ["Diagnostic Imaging", "Interventional Radiology", "MRI", "CT", "Ultrasound"]), + new("Laboratory", ["Clinical Chemistry", "Hematology", "Microbiology", "Blood Bank", "Pathology"]), + new("Pharmacy", ["Inpatient", "Outpatient", "Clinical Pharmacy", "Medication Safety", "Compounding"]), + new("Physical Therapy", ["Inpatient Rehab", "Outpatient Rehab", "Occupational Therapy", "Speech Therapy"]), + new("Mental Health", ["Psychiatry", "Psychology", "Social Work", "Substance Abuse", "Crisis Services"]), + new("Compliance", ["Regulatory Affairs", "HIPAA Privacy", "Risk Management", "Internal Audit", "Ethics"]), + new("Finance", ["Revenue Cycle", "Billing", "Insurance Verification", "Financial Counseling", "Cost Accounting"]), + new("Information Technology", ["EHR Systems", "Infrastructure", "Cybersecurity", "Telehealth", "Help Desk"]) + ]); + + internal static readonly OrgStructure Startup = new(OrgStructureModel.Startup, + [ + new("Product", ["Product Management", "Design", "Research"]), + new("Engineering", ["Backend", "Frontend", "Infrastructure"]), + new("Growth", ["Marketing", "Sales", "Partnerships"]), + new("Operations", ["Finance", "Legal", "Customer Support"]), + new("People", ["Recruiting", "Culture", "Benefits"]) + ]); + + internal static readonly OrgStructure[] All = [Traditional, Spotify, Modern, Government, SchoolDistrict, Healthcare, Startup]; internal static OrgStructure GetStructure(OrgStructureModel model) => model switch { OrgStructureModel.Traditional => Traditional, OrgStructureModel.Spotify => Spotify, OrgStructureModel.Modern => Modern, - _ => Traditional + OrgStructureModel.Government => Government, + OrgStructureModel.SchoolDistrict => SchoolDistrict, + OrgStructureModel.Healthcare => Healthcare, + OrgStructureModel.Startup => Startup, + _ => throw new ArgumentOutOfRangeException(nameof(model), model, $"Unknown org structure model: {model}") }; } diff --git a/util/Seeder/Options/OrganizationVaultOptions.cs b/util/Seeder/Options/OrganizationVaultOptions.cs index 18ce00e6f0a0..320e4d1e6903 100644 --- a/util/Seeder/Options/OrganizationVaultOptions.cs +++ b/util/Seeder/Options/OrganizationVaultOptions.cs @@ -35,6 +35,12 @@ public class OrganizationVaultOptions /// public int Groups { get; init; } = 0; + /// + /// Number of collections to create. When 0 and no is set, + /// falls back to 1 collection if ciphers are requested. + /// + public int Collections { get; init; } = 0; + /// /// When true and Users >= 10, creates a realistic mix of user statuses: /// 85% Confirmed, 5% Invited, 5% Accepted, 5% Revoked. diff --git a/util/Seeder/Pipeline/RecipeOrchestrator.cs b/util/Seeder/Pipeline/RecipeOrchestrator.cs index 8f919f80cae8..74fcab9d794c 100644 --- a/util/Seeder/Pipeline/RecipeOrchestrator.cs +++ b/util/Seeder/Pipeline/RecipeOrchestrator.cs @@ -75,6 +75,10 @@ internal ExecutionResult Execute( { builder.AddCollections(options.StructureModel.Value); } + else if (options.Collections > 0) + { + builder.AddCollections(options.Collections, options.Density); + } else if (options.Ciphers > 0) { builder.AddCollections(1, options.Density); diff --git a/util/SeederUtility/Commands/OrganizationArgs.cs b/util/SeederUtility/Commands/OrganizationArgs.cs index 8956b1773b88..f8dc7ab11fd6 100644 --- a/util/SeederUtility/Commands/OrganizationArgs.cs +++ b/util/SeederUtility/Commands/OrganizationArgs.cs @@ -1,4 +1,5 @@ -using Bit.Seeder.Data.Enums; +using Bit.Seeder.Data.Distributions; +using Bit.Seeder.Data.Enums; using Bit.Seeder.Factories; using Bit.Seeder.Options; using CommandDotNet; @@ -26,10 +27,16 @@ public class OrganizationArgs : IArgumentModel [Option('g', "groups", Description = "Number of groups to create (default: 0, no groups)")] public int? Groups { get; set; } + [Option("collections", Description = "Number of collections to create (default: 0). Required for density profiles to be useful.")] + public int? Collections { get; set; } + + [Option("density", Description = "Named density profile: balanced, highPerm, highCollection, broad, minimal, groupHeavy, or sparse")] + public string? Density { get; set; } + [Option('m', "mix-user-statuses", Description = "Use realistic status mix (85% confirmed, 5% each invited/accepted/revoked). Requires >= 10 users.")] public bool MixStatuses { get; set; } = true; - [Option('o', "org-structure", Description = "Org structure for collections: Traditional, Spotify, or Modern")] + [Option('o', "org-structure", Description = "Org structure for collections: Traditional, Spotify, Modern, Government, SchoolDistrict, Healthcare, or Startup")] public string? Structure { get; set; } [Option('r', "region", Description = "Geographic region for names: NorthAmerica, Europe, AsiaPacific, LatinAmerica, MiddleEast, Africa, or Global")] @@ -66,6 +73,11 @@ public void Validate() ParseGeographicRegion(Region); } + if (!string.IsNullOrEmpty(Density)) + { + DensityProfiles.Parse(Density); + } + PlanFeatures.Parse(PlanType); } @@ -76,9 +88,11 @@ public void Validate() Users = Users, Ciphers = Ciphers ?? 0, Groups = Groups ?? 0, + Collections = Collections ?? 0, RealisticStatusMix = MixStatuses, StructureModel = ParseOrgStructure(Structure), Region = ParseGeographicRegion(Region), + Density = DensityProfiles.Parse(Density), Password = Password, PlanType = PlanFeatures.Parse(PlanType) }; @@ -95,7 +109,12 @@ public void Validate() "traditional" => OrgStructureModel.Traditional, "spotify" => OrgStructureModel.Spotify, "modern" => OrgStructureModel.Modern, - _ => throw new ArgumentException($"Unknown structure '{structure}'. Use: Traditional, Spotify, or Modern") + "government" => OrgStructureModel.Government, + "schooldistrict" => OrgStructureModel.SchoolDistrict, + "healthcare" => OrgStructureModel.Healthcare, + "startup" => OrgStructureModel.Startup, + _ => throw new ArgumentException( + $"Unknown structure '{structure}'. Use: Traditional, Spotify, Modern, Government, SchoolDistrict, Healthcare, or Startup") }; } From 31fe66ec9ec240502f39e05adea029624f1ea075 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Thu, 12 Mar 2026 12:48:19 -0400 Subject: [PATCH 69/85] feat(emergency-access): [PM-29585] Prevent New EA Invitations or Acceptance (#6940) * feat(emergency-access): [PM-29585] Prevent New EA Invitations or Acceptance - Initial implementation * fix(emergency-access): [PM-29585] Prevent New EA Invitations or Acceptance - Changes in a good place. Need to write tests. * test(emergency-access): [PM-29585] Prevent New EA Invitations or Acceptance - Service tests have been added. * fix(emergency-access): [PM-29585] Prevent New EA Invitations or Acceptance - Fixed comment. --- ...omaticUserConfirmationPolicyRequirement.cs | 20 ++- .../EmergencyAccess/EmergencyAccessService.cs | 34 +++- ...cUserConfirmationPolicyRequirementTests.cs | 152 ++++++++++++++++ .../EmergencyAccessServiceTests.cs | 170 +++++++++++++++++- 4 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirementTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs index 9b6cf862575c..adc9b46147a4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs @@ -19,7 +19,25 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements /// Collection of policy details that apply to this user id public class AutomaticUserConfirmationPolicyRequirement(IEnumerable policyDetails) : IPolicyRequirement { - public bool CannotHaveEmergencyAccess() => policyDetails.Any(); + /// + /// Returns true if the user cannot invite to emergency access because they are in an + /// auto-confirm organization with status Accepted, Confirmed, or Revoked. + /// + public bool GrantorCannotInviteToEmergencyAccess() => policyDetails.Any(p => + p.OrganizationUserStatus is + OrganizationUserStatusType.Accepted or + OrganizationUserStatusType.Confirmed or + OrganizationUserStatusType.Revoked); + + /// + /// Returns true if the user cannot accept emergency access because they are in an + /// auto-confirm organization with status Accepted, Confirmed, or Revoked. + /// + public bool GranteeCannotAcceptEmergencyAccess() => policyDetails.Any(p => + p.OrganizationUserStatus is + OrganizationUserStatusType.Accepted or + OrganizationUserStatusType.Confirmed or + OrganizationUserStatusType.Revoked); public bool CannotJoinProvider() => policyDetails.Any(); diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs index 8256fc2037df..c86a51c2734a 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs @@ -3,6 +3,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Business.Tokenables; @@ -33,6 +35,8 @@ public class EmergencyAccessService : IEmergencyAccessService private readonly GlobalSettings _globalSettings; private readonly IDataProtectorTokenFactory _dataProtectorTokenizer; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; + private readonly IFeatureService _featureService; + private readonly IPolicyRequirementQuery _policyRequirementQuery; public EmergencyAccessService( IEmergencyAccessRepository emergencyAccessRepository, @@ -45,7 +49,9 @@ public EmergencyAccessService( IUserService userService, GlobalSettings globalSettings, IDataProtectorTokenFactory dataProtectorTokenizer, - IRemoveOrganizationUserCommand removeOrganizationUserCommand) + IRemoveOrganizationUserCommand removeOrganizationUserCommand, + IFeatureService featureService, + IPolicyRequirementQuery policyRequirementQuery) { _emergencyAccessRepository = emergencyAccessRepository; _organizationUserRepository = organizationUserRepository; @@ -58,6 +64,8 @@ public EmergencyAccessService( _globalSettings = globalSettings; _dataProtectorTokenizer = dataProtectorTokenizer; _removeOrganizationUserCommand = removeOrganizationUserCommand; + _featureService = featureService; + _policyRequirementQuery = policyRequirementQuery; } public async Task InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime) @@ -72,6 +80,17 @@ public EmergencyAccessService( throw new BadRequestException("You cannot use Emergency Access Takeover because you are using Key Connector."); } + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var requirement = await _policyRequirementQuery + .GetAsync(grantorUser.Id); + + if (requirement.GrantorCannotInviteToEmergencyAccess()) + { + throw new BadRequestException("You cannot invite emergency contacts because you are a member of an organization that uses Automatic User Confirmation."); + } + } + var emergencyAccess = new Entities.EmergencyAccess { GrantorId = grantorUser.Id, @@ -130,6 +149,17 @@ public async Task ResendInviteAsync(User grantorUser, Guid emergencyAccessId) throw new BadRequestException("Invalid token."); } + if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) + { + var requirement = await _policyRequirementQuery + .GetAsync(granteeUser.Id); + + if (requirement.GranteeCannotAcceptEmergencyAccess()) + { + throw new BadRequestException("You cannot accept emergency access invitations because you are a member of an organization that uses Automatic User Confirmation."); + } + } + if (emergencyAccess.Status == EmergencyAccessStatusType.Accepted) { throw new BadRequestException("Invitation already accepted. You will receive an email when the grantor confirms you as an emergency access contact."); @@ -161,7 +191,7 @@ public async Task ResendInviteAsync(User grantorUser, Guid emergencyAccessId) return emergencyAccess; } - // TODO: remove with PM-31327 when we migrate to the command. + // TODO: remove with PM-31327 when we migrate to the command. public async Task DeleteAsync(Guid emergencyAccessId, Guid userId) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirementTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirementTests.cs new file mode 100644 index 000000000000..3b9964b9a2d7 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirementTests.cs @@ -0,0 +1,152 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.Enums; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; + +public class AutomaticUserConfirmationPolicyRequirementTests +{ + [Theory] + [InlineData(OrganizationUserStatusType.Accepted)] + [InlineData(OrganizationUserStatusType.Confirmed)] + [InlineData(OrganizationUserStatusType.Revoked)] + public void CannotGrantEmergencyAccess_WithActiveStatus_ReturnsTrue(OrganizationUserStatusType status) + { + var policyDetails = new[] + { + new PolicyDetails + { + OrganizationId = Guid.NewGuid(), + PolicyType = PolicyType.AutomaticUserConfirmation, + OrganizationUserStatus = status + } + }; + + var sut = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + + Assert.True(sut.GrantorCannotInviteToEmergencyAccess()); + } + + [Fact] + public void CannotGrantEmergencyAccess_WithInvitedStatus_ReturnsFalse() + { + var policyDetails = new[] + { + new PolicyDetails + { + OrganizationId = Guid.NewGuid(), + PolicyType = PolicyType.AutomaticUserConfirmation, + OrganizationUserStatus = OrganizationUserStatusType.Invited + } + }; + + var sut = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + + Assert.False(sut.GrantorCannotInviteToEmergencyAccess()); + } + + [Fact] + public void CannotGrantEmergencyAccess_WithNoPolicies_ReturnsFalse() + { + var sut = new AutomaticUserConfirmationPolicyRequirement([]); + + Assert.False(sut.GrantorCannotInviteToEmergencyAccess()); + } + + [Theory] + [InlineData(OrganizationUserStatusType.Accepted)] + [InlineData(OrganizationUserStatusType.Confirmed)] + [InlineData(OrganizationUserStatusType.Revoked)] + public void CannotBeGrantedEmergencyAccess_WithActiveStatus_ReturnsTrue(OrganizationUserStatusType status) + { + var policyDetails = new[] + { + new PolicyDetails + { + OrganizationId = Guid.NewGuid(), + PolicyType = PolicyType.AutomaticUserConfirmation, + OrganizationUserStatus = status + } + }; + + var sut = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + + Assert.True(sut.GranteeCannotAcceptEmergencyAccess()); + } + + [Fact] + public void CannotBeGrantedEmergencyAccess_WithInvitedStatus_ReturnsFalse() + { + var policyDetails = new[] + { + new PolicyDetails + { + OrganizationId = Guid.NewGuid(), + PolicyType = PolicyType.AutomaticUserConfirmation, + OrganizationUserStatus = OrganizationUserStatusType.Invited + } + }; + + var sut = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + + Assert.False(sut.GranteeCannotAcceptEmergencyAccess()); + } + + [Fact] + public void CannotBeGrantedEmergencyAccess_WithNoPolicies_ReturnsFalse() + { + var sut = new AutomaticUserConfirmationPolicyRequirement([]); + + Assert.False(sut.GranteeCannotAcceptEmergencyAccess()); + } + + [Fact] + public void CannotGrantEmergencyAccess_WithMultiplePolicies_OneActive_ReturnsTrue() + { + var policyDetails = new[] + { + new PolicyDetails + { + OrganizationId = Guid.NewGuid(), + PolicyType = PolicyType.AutomaticUserConfirmation, + OrganizationUserStatus = OrganizationUserStatusType.Invited + }, + new PolicyDetails + { + OrganizationId = Guid.NewGuid(), + PolicyType = PolicyType.AutomaticUserConfirmation, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed + } + }; + + var sut = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + + Assert.True(sut.GrantorCannotInviteToEmergencyAccess()); + } + + [Fact] + public void CannotBeGrantedEmergencyAccess_WithMultiplePolicies_OneActive_ReturnsTrue() + { + var policyDetails = new[] + { + new PolicyDetails + { + OrganizationId = Guid.NewGuid(), + PolicyType = PolicyType.AutomaticUserConfirmation, + OrganizationUserStatus = OrganizationUserStatusType.Invited + }, + new PolicyDetails + { + OrganizationId = Guid.NewGuid(), + PolicyType = PolicyType.AutomaticUserConfirmation, + OrganizationUserStatus = OrganizationUserStatusType.Confirmed + } + }; + + var sut = new AutomaticUserConfirmationPolicyRequirement(policyDetails); + + Assert.True(sut.GranteeCannotAcceptEmergencyAccess()); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs index acd022aa68de..0f8876b413bd 100644 --- a/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/EmergencyAccess/EmergencyAccessServiceTests.cs @@ -1,4 +1,7 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; @@ -76,6 +79,64 @@ await sutProvider.GetDependency() .SendEmergencyAccessInviteEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); } + [Theory, BitAutoData] + public async Task InviteAsync_FeatureFlagEnabled_GrantorInAutoConfirmOrg_ThrowsBadRequest( + SutProvider sutProvider, User invitingUser, string email, int waitTime) + { + sutProvider.GetDependency().CanAccessPremium(invitingUser).Returns(true); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + sutProvider.GetDependency() + .GetAsync(invitingUser.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([ + new PolicyDetails { OrganizationUserStatus = OrganizationUserStatusType.Confirmed } + ])); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.InviteAsync(invitingUser, email, EmergencyAccessType.Takeover, waitTime)); + + Assert.Contains("You cannot invite emergency contacts because you are a member of an organization that uses Automatic User Confirmation.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().CreateAsync(default); + } + + [Theory, BitAutoData] + public async Task InviteAsync_FeatureFlagEnabled_GrantorNotInAutoConfirmOrg_Succeeds( + SutProvider sutProvider, User invitingUser, string email, int waitTime) + { + sutProvider.GetDependency().CanAccessPremium(invitingUser).Returns(true); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + sutProvider.GetDependency() + .GetAsync(invitingUser.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([])); + + var result = await sutProvider.Sut.InviteAsync(invitingUser, email, EmergencyAccessType.Takeover, waitTime); + + Assert.NotNull(result); + await sutProvider.GetDependency() + .Received(1).CreateAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task InviteAsync_FeatureFlagDisabled_GrantorInAutoConfirmOrg_Succeeds( + SutProvider sutProvider, User invitingUser, string email, int waitTime) + { + sutProvider.GetDependency().CanAccessPremium(invitingUser).Returns(true); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(false); + + var result = await sutProvider.Sut.InviteAsync(invitingUser, email, EmergencyAccessType.Takeover, waitTime); + + Assert.NotNull(result); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task GetAsync_EmergencyAccessNull_ThrowsBadRequest( SutProvider sutProvider, User user) @@ -333,6 +394,113 @@ public async Task AcceptUserAsync_EmergencyAccessEmailDoesNotMatch_ThrowsBadRequ Assert.Contains("User email does not match invite.", exception.Message); } + [Theory, BitAutoData] + public async Task AcceptUserAsync_FeatureFlagEnabled_GranteeInAutoConfirmOrg_ThrowsBadRequest( + SutProvider sutProvider, + User acceptingUser, + Core.Auth.Entities.EmergencyAccess emergencyAccess, + string token) + { + emergencyAccess.Status = EmergencyAccessStatusType.Invited; + emergencyAccess.Email = acceptingUser.Email; + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + sutProvider.GetDependency>() + .TryUnprotect(token, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 1); + return true; + }); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + sutProvider.GetDependency() + .GetAsync(acceptingUser.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([ + new PolicyDetails { OrganizationUserStatus = OrganizationUserStatusType.Confirmed } + ])); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AcceptUserAsync(emergencyAccess.Id, acceptingUser, token, sutProvider.GetDependency())); + + Assert.Contains("You cannot accept emergency access invitations because you are a member of an organization that uses Automatic User Confirmation.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().ReplaceAsync(default); + } + + [Theory, BitAutoData] + public async Task AcceptUserAsync_FeatureFlagEnabled_GranteeNotInAutoConfirmOrg_Succeeds( + SutProvider sutProvider, + User acceptingUser, + User invitingUser, + Core.Auth.Entities.EmergencyAccess emergencyAccess, + string token) + { + emergencyAccess.Status = EmergencyAccessStatusType.Invited; + emergencyAccess.Email = acceptingUser.Email; + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + sutProvider.GetDependency() + .GetUserByIdAsync(Arg.Any()) + .Returns(invitingUser); + sutProvider.GetDependency>() + .TryUnprotect(token, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 1); + return true; + }); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(true); + sutProvider.GetDependency() + .GetAsync(acceptingUser.Id) + .Returns(new AutomaticUserConfirmationPolicyRequirement([])); + + await sutProvider.Sut.AcceptUserAsync(emergencyAccess.Id, acceptingUser, token, sutProvider.GetDependency()); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(x => x.Status == EmergencyAccessStatusType.Accepted)); + } + + [Theory, BitAutoData] + public async Task AcceptUserAsync_FeatureFlagDisabled_GranteeInAutoConfirmOrg_Succeeds( + SutProvider sutProvider, + User acceptingUser, + User invitingUser, + Core.Auth.Entities.EmergencyAccess emergencyAccess, + string token) + { + emergencyAccess.Status = EmergencyAccessStatusType.Invited; + emergencyAccess.Email = acceptingUser.Email; + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(emergencyAccess); + sutProvider.GetDependency() + .GetUserByIdAsync(Arg.Any()) + .Returns(invitingUser); + sutProvider.GetDependency>() + .TryUnprotect(token, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new EmergencyAccessInviteTokenable(emergencyAccess, 1); + return true; + }); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers) + .Returns(false); + + await sutProvider.Sut.AcceptUserAsync(emergencyAccess.Id, acceptingUser, token, sutProvider.GetDependency()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .GetAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task AcceptUserAsync_ReplaceEmergencyAccess_SendsEmail_Success( SutProvider sutProvider, From 95fc7d80adb5c22b8f5f172d443278da1c206f1d Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Thu, 12 Mar 2026 14:04:17 -0400 Subject: [PATCH 70/85] [PM-31820] added a null check to the id/partial route (#7066) --- src/Api/Vault/Controllers/CiphersController.cs | 10 ++++++++-- .../Controllers/CiphersControllerTests.cs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 03b545efeed5..eb658eacf1d1 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -709,12 +709,18 @@ private async Task CanEditItemsInCollections(Guid organizationId, IEnumera public async Task PutPartial(Guid id, [FromBody] CipherPartialRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); + var cipher = await GetByIdAsync(id, user.Id); + if (cipher == null) + { + throw new NotFoundException(); + } + var folderId = string.IsNullOrWhiteSpace(model.FolderId) ? null : (Guid?)new Guid(model.FolderId); await _cipherRepository.UpdatePartialAsync(id, user.Id, folderId, model.Favorite); - var cipher = await GetByIdAsync(id, user.Id); + var updatedCipher = await GetByIdAsync(id, user.Id); var response = new CipherResponseModel( - cipher, + updatedCipher, user, await _applicationCacheService.GetOrganizationAbilitiesAsync(), _globalSettings); diff --git a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs index 80a4e3571f29..85e6e7ad9367 100644 --- a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs @@ -60,6 +60,24 @@ public async Task PutPartialShouldReturnCipherWithGivenFolderAndFavoriteValues(U Assert.Equal(isFavorite, result.Favorite); } + [Theory, BitAutoData] + public async Task PutPartialShouldThrowNotFoundExceptionWhenCipherDoesNotExist(User user, Guid folderId, SutProvider sutProvider) + { + var isFavorite = true; + var cipherId = Guid.NewGuid(); + + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + sutProvider.GetDependency().GetByIdAsync(cipherId, user.Id).ReturnsNull(); + + var requestAction = async () => await sutProvider.Sut.PutPartial(cipherId, new CipherPartialRequestModel { Favorite = isFavorite, FolderId = folderId.ToString() }); + + await Assert.ThrowsAsync(requestAction); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpdatePartialAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + [Theory, BitAutoData] public async Task PutCollections_vNextShouldThrowExceptionWhenCipherIsNullOrNoOrgValue(Guid id, CipherCollectionsRequestModel model, User user, SutProvider sutProvider) From 3532a92baa26f1cb2adcf784e1442f1da29aeeab Mon Sep 17 00:00:00 2001 From: voommen-livefront Date: Thu, 12 Mar 2026 15:50:45 -0500 Subject: [PATCH 71/85] PM-31923 removed the file size validation check --- src/Api/Dirt/Controllers/OrganizationReportsController.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index e3618405d214..4cdb576a4443 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -106,7 +106,7 @@ public async Task GetLatestOrganizationReportAsync(Guid organizat var response = new OrganizationReportResponseModel(latestReport); var fileData = latestReport.GetReportFile(); - if (fileData is { Validated: true }) + if (fileData != null) { response.ReportFileDownloadUrl = await _storageService.GetReportDataDownloadUrlAsync(latestReport, fileData); } @@ -162,10 +162,11 @@ public async Task CreateOrganizationReportAsync( var report = await _createReportCommand.CreateAsync(request.ToData(organizationId)); var fileData = report.GetReportFile()!; + var reportFileUploadUrl = await _storageService.GetReportFileUploadUrlAsync(report, fileData); return Ok(new OrganizationReportFileResponseModel { - ReportFileUploadUrl = await _storageService.GetReportFileUploadUrlAsync(report, fileData), + ReportFileUploadUrl = reportFileUploadUrl, ReportResponse = new OrganizationReportResponseModel(report), FileUploadType = _storageService.FileUploadType }); From 986f638c36366da6e950b145a528409d5b642ebe Mon Sep 17 00:00:00 2001 From: mkincaid-bw Date: Thu, 12 Mar 2026 16:25:33 -0700 Subject: [PATCH 72/85] Fixed invalid syntax in OrganizationUser_UpdateMany (#6923) --- .../OrganizationUser_UpdateMany.sql | 12 +-- ...1-29_00_FixOrganizationUser_UpdateMany.sql | 86 +++++++++++++++++++ 2 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 util/Migrator/DbScripts/2026-01-29_00_FixOrganizationUser_UpdateMany.sql diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateMany.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateMany.sql index f60372b128bc..3c744f2cb9db 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateMany.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateMany.sql @@ -4,6 +4,8 @@ AS BEGIN SET NOCOUNT ON + DECLARE @UserIds [dbo].[GuidIdArray] + -- Parse the JSON string DECLARE @OrganizationUserInput AS TABLE ( [Id] UNIQUEIDENTIFIER, @@ -75,9 +77,9 @@ BEGIN @OrganizationUserInput OUI ON OU.Id = OUI.Id -- Bump account revision dates - EXEC [dbo].[User_BumpManyAccountRevisionDates] - ( - SELECT [UserId] - FROM @OrganizationUserInput - ) + INSERT INTO @UserIds + SELECT [UserId] + FROM @OrganizationUserInput + + EXEC [dbo].[User_BumpManyAccountRevisionDates] @UserIds END diff --git a/util/Migrator/DbScripts/2026-01-29_00_FixOrganizationUser_UpdateMany.sql b/util/Migrator/DbScripts/2026-01-29_00_FixOrganizationUser_UpdateMany.sql new file mode 100644 index 000000000000..723f734fd74c --- /dev/null +++ b/util/Migrator/DbScripts/2026-01-29_00_FixOrganizationUser_UpdateMany.sql @@ -0,0 +1,86 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_UpdateMany] + @jsonData NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + DECLARE @UserIds [dbo].[GuidIdArray] + + -- Parse the JSON string + DECLARE @OrganizationUserInput AS TABLE ( + [Id] UNIQUEIDENTIFIER, + [OrganizationId] UNIQUEIDENTIFIER, + [UserId] UNIQUEIDENTIFIER, + [Email] NVARCHAR(256), + [Key] VARCHAR(MAX), + [Status] SMALLINT, + [Type] TINYINT, + [ExternalId] NVARCHAR(300), + [CreationDate] DATETIME2(7), + [RevisionDate] DATETIME2(7), + [Permissions] NVARCHAR(MAX), + [ResetPasswordKey] VARCHAR(MAX), + [AccessSecretsManager] BIT + ) + + INSERT INTO @OrganizationUserInput + SELECT + [Id], + [OrganizationId], + [UserId], + [Email], + [Key], + [Status], + [Type], + [ExternalId], + [CreationDate], + [RevisionDate], + [Permissions], + [ResetPasswordKey], + [AccessSecretsManager] + FROM OPENJSON(@jsonData) + WITH ( + [Id] UNIQUEIDENTIFIER '$.Id', + [OrganizationId] UNIQUEIDENTIFIER '$.OrganizationId', + [UserId] UNIQUEIDENTIFIER '$.UserId', + [Email] NVARCHAR(256) '$.Email', + [Key] VARCHAR(MAX) '$.Key', + [Status] SMALLINT '$.Status', + [Type] TINYINT '$.Type', + [ExternalId] NVARCHAR(300) '$.ExternalId', + [CreationDate] DATETIME2(7) '$.CreationDate', + [RevisionDate] DATETIME2(7) '$.RevisionDate', + [Permissions] NVARCHAR (MAX) '$.Permissions', + [ResetPasswordKey] VARCHAR (MAX) '$.ResetPasswordKey', + [AccessSecretsManager] BIT '$.AccessSecretsManager' + ) + + -- Perform the update + UPDATE + OU + SET + [OrganizationId] = OUI.[OrganizationId], + [UserId] = OUI.[UserId], + [Email] = OUI.[Email], + [Key] = OUI.[Key], + [Status] = OUI.[Status], + [Type] = OUI.[Type], + [ExternalId] = OUI.[ExternalId], + [CreationDate] = OUI.[CreationDate], + [RevisionDate] = OUI.[RevisionDate], + [Permissions] = OUI.[Permissions], + [ResetPasswordKey] = OUI.[ResetPasswordKey], + [AccessSecretsManager] = OUI.[AccessSecretsManager] + FROM + [dbo].[OrganizationUser] OU + INNER JOIN + @OrganizationUserInput OUI ON OU.Id = OUI.Id + + -- Bump account revision dates + INSERT INTO @UserIds + SELECT [UserId] + FROM @OrganizationUserInput + + EXEC [dbo].[User_BumpManyAccountRevisionDates] @UserIds +END +GO From 3ecd15b66ccfa5c80b65dd899038e65010ae9bc2 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Fri, 13 Mar 2026 09:32:16 -0400 Subject: [PATCH 73/85] [PM-32665] Fix Cross-Organization IDOR in Bulk User Revoke (#7206) --- .../v2/RevokeOrganizationUserCommand.cs | 11 ++++--- .../v2/RevokeOrganizationUsersRequest.cs | 10 +++--- .../v2/RevokeOrganizationUserCommandTests.cs | 33 +++++++++++++++++++ .../RevokeOrganizationUsersValidatorTests.cs | 5 ++- 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommand.cs index ca501277a7ca..ea3717624a91 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommand.cs @@ -43,14 +43,17 @@ await Task.WhenAll( private async Task CreateValidationRequestsAsync( RevokeOrganizationUsersRequest request) { - var organizationUserToRevoke = await organizationUserRepository + var organizationUser = await organizationUserRepository .GetManyAsync(request.OrganizationUserIdsToRevoke); + var organizationUserToRevoke = organizationUser + .Where(x => x.OrganizationId == request.OrganizationId) + .ToArray(); + return new RevokeOrganizationUsersValidationRequest( request.OrganizationId, - request.OrganizationUserIdsToRevoke, - request.PerformedBy, - organizationUserToRevoke); + organizationUserToRevoke, + request.PerformedBy); } private async Task RevokeValidUsersAsync(ICollection validUsers) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersRequest.cs index 56996ffb5311..bb35966898f3 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersRequest.cs @@ -11,7 +11,9 @@ IActingUser PerformedBy public record RevokeOrganizationUsersValidationRequest( Guid OrganizationId, - ICollection OrganizationUserIdsToRevoke, - IActingUser PerformedBy, - ICollection OrganizationUsersToRevoke -) : RevokeOrganizationUsersRequest(OrganizationId, OrganizationUserIdsToRevoke, PerformedBy); + ICollection OrganizationUsersToRevoke, + IActingUser PerformedBy +) +{ + public ICollection OrganizationUserIdsToRevoke => OrganizationUsersToRevoke.Select(x => x.Id).ToArray(); +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommandTests.cs index a74135794f2f..da67b558f24d 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUserCommandTests.cs @@ -188,6 +188,39 @@ public async Task RevokeUsersAsync_WhenPushNotificationFails_ContinuesProcessing Arg.Any>()); } + [Theory] + [BitAutoData] + public async Task RevokeUsersAsync_FiltersOutUsersFromDifferentOrganization( + SutProvider sutProvider, + Guid organizationId, + Guid actingUserId, + [OrganizationUser()] OrganizationUser orgUser, + [OrganizationUser()] OrganizationUser userFromDifferentOrg) + { + // Arrange + orgUser.OrganizationId = organizationId; + orgUser.UserId = Guid.NewGuid(); + + var actingUser = CreateActingUser(actingUserId, false, null); + var request = new RevokeOrganizationUsersRequest( + organizationId, + [orgUser.Id, userFromDifferentOrg.Id], + actingUser); + + SetupRepositoryMocks(sutProvider, [orgUser, userFromDifferentOrg]); + SetupValidatorMock(sutProvider, [ValidationResultHelpers.Valid(orgUser)]); + + // Act + await sutProvider.Sut.RevokeUsersAsync(request); + + // Assert: validator only receives the user from the correct organization + await sutProvider.GetDependency() + .Received(1) + .ValidateAsync(Arg.Is(r => + r.OrganizationUsersToRevoke.Count == 1 && + r.OrganizationUsersToRevoke.Single().Id == orgUser.Id)); + } + private static IActingUser CreateActingUser(Guid? userId, bool isOwnerOrProvider, EventSystemUser? systemUserType) => (userId, systemUserType) switch { diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidatorTests.cs index cfeffb6e1e8d..6c4d1a6942c2 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RevokeUser/v2/RevokeOrganizationUsersValidatorTests.cs @@ -346,9 +346,8 @@ private static RevokeOrganizationUsersValidationRequest CreateValidationRequest( { return new RevokeOrganizationUsersValidationRequest( organizationId, - organizationUsers.Select(u => u.Id).ToList(), - actingUser, - organizationUsers + organizationUsers, + actingUser ); } } From d29fcff2337eb7436c659ffdb74f124266010318 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Fri, 13 Mar 2026 14:35:42 +0100 Subject: [PATCH 74/85] Decouple seeder cipher encryption from internal vault crates (#7211) --- .../EncryptPropertyAttributeTests.cs | 161 ++ .../RustSdkCipherTests.cs | 339 ++- util/RustSdk/RustSdkService.cs | 38 +- util/RustSdk/rust/Cargo.lock | 2223 +---------------- util/RustSdk/rust/Cargo.toml | 2 - util/RustSdk/rust/src/cipher.rs | 489 ++-- .../Attributes/EncryptPropertyAttribute.cs | 91 + util/Seeder/CLAUDE.md | 13 +- util/Seeder/Factories/CipherEncryption.cs | 16 +- util/Seeder/Models/CipherViewDto.cs | 79 +- util/Seeder/README.md | 10 +- util/SeederUtility/README.md | 12 +- 12 files changed, 774 insertions(+), 2699 deletions(-) create mode 100644 test/SeederApi.IntegrationTest/EncryptPropertyAttributeTests.cs create mode 100644 util/Seeder/Attributes/EncryptPropertyAttribute.cs diff --git a/test/SeederApi.IntegrationTest/EncryptPropertyAttributeTests.cs b/test/SeederApi.IntegrationTest/EncryptPropertyAttributeTests.cs new file mode 100644 index 000000000000..50a9f84d3884 --- /dev/null +++ b/test/SeederApi.IntegrationTest/EncryptPropertyAttributeTests.cs @@ -0,0 +1,161 @@ +using Bit.Seeder.Attributes; +using Bit.Seeder.Models; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest; + +public sealed class EncryptPropertyAttributeTests +{ + [Fact] + public void GetFieldPaths_CipherViewDto_ReturnsCorrectCount() + { + var paths = EncryptPropertyAttribute.GetFieldPaths(); + + // 2 top-level (name, notes) + // 3 login (username, password, totp) + // 2 loginUri[*] (uri, uriChecksum) + // 6 card + // 18 identity + // 3 sshKey + // 2 fields[*] (name, value) + Assert.Equal(36, paths.Length); + } + + [Fact] + public void GetFieldPaths_CipherViewDto_TopLevel() + { + var paths = ToSet(); + + Assert.Contains("name", paths); + Assert.Contains("notes", paths); + } + + [Fact] + public void GetFieldPaths_CipherViewDto_Login() + { + var paths = ToSet(); + + Assert.Contains("login.username", paths); + Assert.Contains("login.password", paths); + Assert.Contains("login.totp", paths); + Assert.Contains("login.uris[*].uri", paths); + Assert.Contains("login.uris[*].uriChecksum", paths); + } + + [Fact] + public void GetFieldPaths_CipherViewDto_Card() + { + var paths = ToSet(); + + Assert.Contains("card.cardholderName", paths); + Assert.Contains("card.brand", paths); + Assert.Contains("card.number", paths); + Assert.Contains("card.expMonth", paths); + Assert.Contains("card.expYear", paths); + Assert.Contains("card.code", paths); + } + + [Fact] + public void GetFieldPaths_CipherViewDto_Identity() + { + var paths = ToSet(); + + var expected = new[] + { + "identity.title", "identity.firstName", "identity.middleName", "identity.lastName", + "identity.address1", "identity.address2", "identity.address3", + "identity.city", "identity.state", "identity.postalCode", "identity.country", + "identity.company", "identity.email", "identity.phone", + "identity.ssn", "identity.username", "identity.passportNumber", "identity.licenseNumber" + }; + + foreach (var path in expected) + { + Assert.Contains(path, paths); + } + + Assert.Equal(18, expected.Length); + } + + [Fact] + public void GetFieldPaths_CipherViewDto_SshKey() + { + var paths = ToSet(); + + Assert.Contains("sshKey.privateKey", paths); + Assert.Contains("sshKey.publicKey", paths); + Assert.Contains("sshKey.fingerprint", paths); + } + + [Fact] + public void GetFieldPaths_CipherViewDto_Fields() + { + var paths = ToSet(); + + Assert.Contains("fields[*].name", paths); + Assert.Contains("fields[*].value", paths); + } + + [Fact] + public void GetFieldPaths_CipherViewDto_ExcludesNonEncryptedProperties() + { + var paths = ToSet(); + + Assert.DoesNotContain("id", paths); + Assert.DoesNotContain("organizationId", paths); + Assert.DoesNotContain("folderId", paths); + Assert.DoesNotContain("key", paths); + Assert.DoesNotContain("type", paths); + Assert.DoesNotContain("favorite", paths); + Assert.DoesNotContain("reprompt", paths); + Assert.DoesNotContain("creationDate", paths); + Assert.DoesNotContain("revisionDate", paths); + Assert.DoesNotContain("deletedDate", paths); + } + + [Fact] + public void GetFieldPaths_CipherViewDto_ExcludesNonStringNestedProperties() + { + var paths = ToSet(); + + Assert.DoesNotContain("login.passwordRevisionDate", paths); + Assert.DoesNotContain("login.uris[*].match", paths); + Assert.DoesNotContain("fields[*].type", paths); + Assert.DoesNotContain("fields[*].linkedId", paths); + Assert.DoesNotContain("secureNote.type", paths); + } + + [Fact] + public void GetFieldPaths_UsesJsonPropertyName_NotCSharpPropertyName() + { + var paths = ToSet(); + + // C# property is "CardholderName", JSON path should use "cardholderName" + Assert.Contains("card.cardholderName", paths); + Assert.DoesNotContain("card.CardholderName", paths); + + // C# property is "SSN", JSON path should use "ssn" + Assert.Contains("identity.ssn", paths); + Assert.DoesNotContain("identity.SSN", paths); + } + + [Fact] + public void GetFieldPaths_IsCached() + { + var first = EncryptPropertyAttribute.GetFieldPaths(); + var second = EncryptPropertyAttribute.GetFieldPaths(); + + Assert.Same(first, second); + } + + [Fact] + public void GetFieldPaths_TypeWithNoEncryptedProperties_ReturnsEmpty() + { + var paths = EncryptPropertyAttribute.GetFieldPaths(); + + Assert.Empty(paths); + } + + private static HashSet ToSet() => + new(EncryptPropertyAttribute.GetFieldPaths()); +} diff --git a/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs index c2d458deb633..efd1452eb7e3 100644 --- a/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs +++ b/test/SeederApi.IntegrationTest/RustSdkCipherTests.cs @@ -1,48 +1,74 @@ using System.Text.Json; +using System.Text.Json.Serialization; using Bit.Core.Vault.Models.Data; using Bit.RustSDK; +using Bit.Seeder.Attributes; using Bit.Seeder.Factories; using Bit.Seeder.Models; using Xunit; namespace Bit.SeederApi.IntegrationTest; -public class RustSdkCipherTests +public sealed class RustSdkCipherTests { - private static readonly JsonSerializerOptions SdkJsonOptions = new() + private static readonly JsonSerializerOptions _sdkJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; [Fact] - public void EncryptDecrypt_LoginCipher_RoundtripPreservesPlaintext() + public void EncryptString_DecryptString_Roundtrip() { var orgKeys = RustSdkService.GenerateOrganizationKeys(); + var encrypted = RustSdkService.EncryptString("SuperSecretP@ssw0rd!", orgKeys.Key); - var originalCipher = CreateTestLoginCipher(); - var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); + Assert.StartsWith("2.", encrypted); + Assert.Equal("SuperSecretP@ssw0rd!", RustSdkService.DecryptString(encrypted, orgKeys.Key)); + } - var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key); + [Fact] + public void EncryptFields_DecryptString_Roundtrip() + { + var orgKeys = RustSdkService.GenerateOrganizationKeys(); - Assert.DoesNotContain("\"error\"", encryptedJson); - Assert.Contains("\"name\":\"2.", encryptedJson); + var cipher = new CipherViewDto + { + Name = "Test Login", + Notes = "Secret notes about this login", + Type = CipherTypes.Login, + Login = new LoginViewDto + { + Username = "testuser@example.com", + Password = "SuperSecretP@ssw0rd!", + Uris = [new LoginUriViewDto { Uri = "https://example.com" }] + } + }; + + var json = JsonSerializer.Serialize(cipher, _sdkJsonOptions); + var fieldPathsJson = JsonSerializer.Serialize(EncryptPropertyAttribute.GetFieldPaths()); + var encryptedJson = RustSdkService.EncryptFields(json, fieldPathsJson, orgKeys.Key); - var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key); + using var doc = JsonDocument.Parse(encryptedJson); + var root = doc.RootElement; - Assert.DoesNotContain("\"error\"", decryptedJson); + var encryptedName = root.GetProperty("name").GetString()!; + Assert.StartsWith("2.", encryptedName); - var decryptedCipher = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); + var decryptedName = RustSdkService.DecryptString(encryptedName, orgKeys.Key); + Assert.Equal("Test Login", decryptedName); - Assert.NotNull(decryptedCipher); - Assert.Equal(originalCipher.Name, decryptedCipher.Name); - Assert.Equal(originalCipher.Notes, decryptedCipher.Notes); - Assert.Equal(originalCipher.Login?.Username, decryptedCipher.Login?.Username); - Assert.Equal(originalCipher.Login?.Password, decryptedCipher.Login?.Password); + var encryptedUsername = root.GetProperty("login").GetProperty("username").GetString()!; + var decryptedUsername = RustSdkService.DecryptString(encryptedUsername, orgKeys.Key); + Assert.Equal("testuser@example.com", decryptedUsername); + + var encryptedUri = root.GetProperty("login").GetProperty("uris")[0].GetProperty("uri").GetString()!; + var decryptedUri = RustSdkService.DecryptString(encryptedUri, orgKeys.Key); + Assert.Equal("https://example.com", decryptedUri); } [Fact] - public void EncryptCipher_WithUri_EncryptsAllFields() + public void EncryptFields_NoPlaintextLeakage() { var orgKeys = RustSdkService.GenerateOrganizationKeys(); @@ -63,39 +89,31 @@ public void EncryptCipher_WithUri_EncryptsAllFields() } }; - var cipherJson = JsonSerializer.Serialize(cipher, SdkJsonOptions); - var encryptedJson = RustSdkService.EncryptCipher(cipherJson, orgKeys.Key); + var json = JsonSerializer.Serialize(cipher, _sdkJsonOptions); + var fieldPathsJson = JsonSerializer.Serialize(EncryptPropertyAttribute.GetFieldPaths()); + var encryptedJson = RustSdkService.EncryptFields(json, fieldPathsJson, orgKeys.Key); - Assert.DoesNotContain("\"error\"", encryptedJson); Assert.DoesNotContain("Amazon Shopping", encryptedJson); Assert.DoesNotContain("shopper@example.com", encryptedJson); Assert.DoesNotContain("MySecretPassword123!", encryptedJson); + Assert.DoesNotContain("Prime member since 2020", encryptedJson); + Assert.Contains("\"name\":\"2.", encryptedJson); } [Fact] - public void DecryptCipher_WithWrongKey_FailsOrProducesGarbage() + public void DecryptString_WithWrongKey_Throws() { var encryptionKey = RustSdkService.GenerateOrganizationKeys(); var differentKey = RustSdkService.GenerateOrganizationKeys(); - var originalCipher = CreateTestLoginCipher(); - var cipherJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); - - var encryptedJson = RustSdkService.EncryptCipher(cipherJson, encryptionKey.Key); - Assert.DoesNotContain("\"error\"", encryptedJson); + var encrypted = RustSdkService.EncryptString("secret value", encryptionKey.Key); - var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, differentKey.Key); - - var decryptionFailedWithError = decryptedJson.Contains("\"error\""); - if (!decryptionFailedWithError) - { - var decrypted = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); - Assert.NotEqual(originalCipher.Name, decrypted?.Name); - } + Assert.Throws(() => + RustSdkService.DecryptString(encrypted, differentKey.Key)); } [Fact] - public void EncryptCipher_WithFields_EncryptsCustomFields() + public void EncryptFields_WithCustomFields_EncryptsFieldNameAndValue() { var orgKeys = RustSdkService.GenerateOrganizationKeys(); @@ -115,20 +133,23 @@ public void EncryptCipher_WithFields_EncryptsCustomFields() ] }; - var cipherJson = JsonSerializer.Serialize(cipher, SdkJsonOptions); - var encryptedJson = RustSdkService.EncryptCipher(cipherJson, orgKeys.Key); + var json = JsonSerializer.Serialize(cipher, _sdkJsonOptions); + var fieldPathsJson = JsonSerializer.Serialize(EncryptPropertyAttribute.GetFieldPaths()); + var encryptedJson = RustSdkService.EncryptFields(json, fieldPathsJson, orgKeys.Key); - Assert.DoesNotContain("\"error\"", encryptedJson); - Assert.DoesNotContain("sk-secret-api-key-12345", encryptedJson); + Assert.DoesNotContain("sk_test_FAKE_api_key_12345", encryptedJson); Assert.DoesNotContain("client-id-xyz", encryptedJson); - var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key); - var decrypted = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); + using var doc = JsonDocument.Parse(encryptedJson); + var fields = doc.RootElement.GetProperty("fields"); - Assert.NotNull(decrypted?.Fields); - Assert.Equal(2, decrypted.Fields.Count); - Assert.Equal("API Key", decrypted.Fields[0].Name); - Assert.Equal("sk_test_FAKE_api_key_12345", decrypted.Fields[0].Value); + var field0Name = fields[0].GetProperty("name").GetString()!; + var field0Value = fields[0].GetProperty("value").GetString()!; + Assert.StartsWith("2.", field0Name); + Assert.StartsWith("2.", field0Value); + + Assert.Equal("API Key", RustSdkService.DecryptString(field0Name, orgKeys.Key)); + Assert.Equal("sk_test_FAKE_api_key_12345", RustSdkService.DecryptString(field0Value, orgKeys.Key)); } [Fact] @@ -137,7 +158,6 @@ public void CipherSeeder_ProducesServerCompatibleFormat() var orgKeys = RustSdkService.GenerateOrganizationKeys(); var orgId = Guid.NewGuid(); - // Create cipher using the seeder var cipher = LoginCipherSeeder.Create( orgKeys.Key, name: "GitHub Account", @@ -155,7 +175,7 @@ public void CipherSeeder_ProducesServerCompatibleFormat() var loginData = JsonSerializer.Deserialize(cipher.Data); Assert.NotNull(loginData); - var encStringPrefix = "2."; + const string encStringPrefix = "2."; Assert.StartsWith(encStringPrefix, loginData.Name); Assert.StartsWith(encStringPrefix, loginData.Username); Assert.StartsWith(encStringPrefix, loginData.Password); @@ -194,7 +214,7 @@ public void CipherSeeder_WithFields_ProducesCorrectServerFormat() var fields = loginData.Fields.ToList(); Assert.Equal(2, fields.Count); - var encStringPrefix = "2."; + const string encStringPrefix = "2."; Assert.StartsWith(encStringPrefix, fields[0].Name); Assert.StartsWith(encStringPrefix, fields[0].Value); Assert.StartsWith(encStringPrefix, fields[1].Name); @@ -207,36 +227,18 @@ public void CipherSeeder_WithFields_ProducesCorrectServerFormat() Assert.DoesNotContain("sk_test_FAKE_abc123", cipher.Data); } - private static CipherViewDto CreateTestLoginCipher() - { - return new CipherViewDto - { - Name = "Test Login", - Notes = "Secret notes about this login", - Type = CipherTypes.Login, - Login = new LoginViewDto - { - Username = "testuser@example.com", - Password = "SuperSecretP@ssw0rd!", - Uris = [new LoginUriViewDto { Uri = "https://example.com" }] - } - }; - } - [Fact] - public void EncryptDecrypt_CardCipher_RoundtripPreservesPlaintext() + public void EncryptFields_CardCipher_RoundtripDecrypt() { var orgKeys = RustSdkService.GenerateOrganizationKeys(); - var originalCipher = new CipherViewDto + var cipher = new CipherViewDto { Name = "My Visa Card", - Notes = "Primary card for online purchases", Type = CipherTypes.Card, Card = new CardViewDto { CardholderName = "John Doe", - Brand = "Visa", Number = "4111111111111111", ExpMonth = "12", ExpYear = "2028", @@ -244,20 +246,87 @@ public void EncryptDecrypt_CardCipher_RoundtripPreservesPlaintext() } }; - var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); - var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key); + var json = JsonSerializer.Serialize(cipher, _sdkJsonOptions); + var fieldPathsJson = JsonSerializer.Serialize(EncryptPropertyAttribute.GetFieldPaths()); + var encryptedJson = RustSdkService.EncryptFields(json, fieldPathsJson, orgKeys.Key); + + using var doc = JsonDocument.Parse(encryptedJson); + var card = doc.RootElement.GetProperty("card"); - Assert.DoesNotContain("\"error\"", encryptedJson); - Assert.DoesNotContain("4111111111111111", encryptedJson); - Assert.DoesNotContain("John Doe", encryptedJson); + Assert.Equal("John Doe", RustSdkService.DecryptString(card.GetProperty("cardholderName").GetString()!, orgKeys.Key)); + Assert.Equal("4111111111111111", RustSdkService.DecryptString(card.GetProperty("number").GetString()!, orgKeys.Key)); + Assert.Equal("12", RustSdkService.DecryptString(card.GetProperty("expMonth").GetString()!, orgKeys.Key)); + Assert.Equal("2028", RustSdkService.DecryptString(card.GetProperty("expYear").GetString()!, orgKeys.Key)); + Assert.Equal("123", RustSdkService.DecryptString(card.GetProperty("code").GetString()!, orgKeys.Key)); + } - var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key); - var decrypted = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); + [Fact] + public void EncryptFields_IdentityCipher_RoundtripDecrypt() + { + var orgKeys = RustSdkService.GenerateOrganizationKeys(); - Assert.NotNull(decrypted?.Card); - Assert.Equal("4111111111111111", decrypted.Card.Number); - Assert.Equal("John Doe", decrypted.Card.CardholderName); - Assert.Equal("123", decrypted.Card.Code); + var cipher = new CipherViewDto + { + Name = "Personal Identity", + Type = CipherTypes.Identity, + Identity = new IdentityViewDto + { + FirstName = "John", + LastName = "Doe", + Email = "john.doe@example.com", + SSN = "123-45-6789", + Address1 = "123 Main Street", + City = "Anytown", + State = "CA", + PostalCode = "90210", + Country = "US" + } + }; + + var json = JsonSerializer.Serialize(cipher, _sdkJsonOptions); + var fieldPathsJson = JsonSerializer.Serialize(EncryptPropertyAttribute.GetFieldPaths()); + var encryptedJson = RustSdkService.EncryptFields(json, fieldPathsJson, orgKeys.Key); + + using var doc = JsonDocument.Parse(encryptedJson); + var identity = doc.RootElement.GetProperty("identity"); + + Assert.Equal("John", RustSdkService.DecryptString(identity.GetProperty("firstName").GetString()!, orgKeys.Key)); + Assert.Equal("123-45-6789", RustSdkService.DecryptString(identity.GetProperty("ssn").GetString()!, orgKeys.Key)); + Assert.Equal("john.doe@example.com", RustSdkService.DecryptString(identity.GetProperty("email").GetString()!, orgKeys.Key)); + Assert.Equal("123 Main Street", RustSdkService.DecryptString(identity.GetProperty("address1").GetString()!, orgKeys.Key)); + Assert.Equal("90210", RustSdkService.DecryptString(identity.GetProperty("postalCode").GetString()!, orgKeys.Key)); + } + + [Fact] + public void EncryptFields_SshKeyCipher_RoundtripDecrypt() + { + var orgKeys = RustSdkService.GenerateOrganizationKeys(); + + var cipher = new CipherViewDto + { + Name = "Dev Key", + Type = CipherTypes.SshKey, + SshKey = new SshKeyViewDto + { + PrivateKey = "-----BEGIN FAKE KEY-----\nMIIE...\n-----END FAKE KEY-----", + PublicKey = "ssh-rsa AAAAB3... user@host", + Fingerprint = "SHA256:abc123" + } + }; + + var json = JsonSerializer.Serialize(cipher, _sdkJsonOptions); + var fieldPathsJson = JsonSerializer.Serialize(EncryptPropertyAttribute.GetFieldPaths()); + var encryptedJson = RustSdkService.EncryptFields(json, fieldPathsJson, orgKeys.Key); + + using var doc = JsonDocument.Parse(encryptedJson); + var sshKey = doc.RootElement.GetProperty("sshKey"); + + Assert.Equal("-----BEGIN FAKE KEY-----\nMIIE...\n-----END FAKE KEY-----", + RustSdkService.DecryptString(sshKey.GetProperty("privateKey").GetString()!, orgKeys.Key)); + Assert.Equal("ssh-rsa AAAAB3... user@host", + RustSdkService.DecryptString(sshKey.GetProperty("publicKey").GetString()!, orgKeys.Key)); + Assert.Equal("SHA256:abc123", + RustSdkService.DecryptString(sshKey.GetProperty("fingerprint").GetString()!, orgKeys.Key)); } [Fact] @@ -294,48 +363,6 @@ public void CipherSeeder_CardCipher_ProducesServerCompatibleFormat() Assert.DoesNotContain("Jane Smith", cipher.Data); } - [Fact] - public void EncryptDecrypt_IdentityCipher_RoundtripPreservesPlaintext() - { - var orgKeys = RustSdkService.GenerateOrganizationKeys(); - - var originalCipher = new CipherViewDto - { - Name = "Personal Identity", - Type = CipherTypes.Identity, - Identity = new IdentityViewDto - { - Title = "Mr", - FirstName = "John", - MiddleName = "Robert", - LastName = "Doe", - Email = "john.doe@example.com", - Phone = "+1-555-123-4567", - SSN = "123-45-6789", - Address1 = "123 Main Street", - City = "Anytown", - State = "CA", - PostalCode = "90210", - Country = "US" - } - }; - - var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); - var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key); - - Assert.DoesNotContain("\"error\"", encryptedJson); - Assert.DoesNotContain("123-45-6789", encryptedJson); - Assert.DoesNotContain("john.doe@example.com", encryptedJson); - - var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key); - var decrypted = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); - - Assert.NotNull(decrypted?.Identity); - Assert.Equal("John", decrypted.Identity.FirstName); - Assert.Equal("123-45-6789", decrypted.Identity.SSN); - Assert.Equal("john.doe@example.com", decrypted.Identity.Email); - } - [Fact] public void CipherSeeder_IdentityCipher_ProducesServerCompatibleFormat() { @@ -369,33 +396,6 @@ public void CipherSeeder_IdentityCipher_ProducesServerCompatibleFormat() Assert.DoesNotContain("Alice", cipher.Data); } - [Fact] - public void EncryptDecrypt_SecureNoteCipher_RoundtripPreservesPlaintext() - { - var orgKeys = RustSdkService.GenerateOrganizationKeys(); - - var originalCipher = new CipherViewDto - { - Name = "API Secrets", - Notes = "sk_test_FAKE_abc123xyz789key", - Type = CipherTypes.SecureNote, - SecureNote = new SecureNoteViewDto { Type = 0 } - }; - - var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); - var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key); - - Assert.DoesNotContain("\"error\"", encryptedJson); - Assert.DoesNotContain("sk_test_FAKE_abc123xyz789key", encryptedJson); - - var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key); - var decrypted = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); - - Assert.NotNull(decrypted); - Assert.Equal("API Secrets", decrypted.Name); - Assert.Equal("sk_test_FAKE_abc123xyz789key", decrypted.Notes); - } - [Fact] public void CipherSeeder_SecureNoteCipher_ProducesServerCompatibleFormat() { @@ -415,47 +415,13 @@ public void CipherSeeder_SecureNoteCipher_ProducesServerCompatibleFormat() Assert.NotNull(noteData); Assert.Equal(Core.Vault.Enums.SecureNoteType.Generic, noteData.Type); - var encStringPrefix = "2."; - Assert.StartsWith(encStringPrefix, noteData.Name); - Assert.StartsWith(encStringPrefix, noteData.Notes); + Assert.StartsWith("2.", noteData.Name); + Assert.StartsWith("2.", noteData.Notes); Assert.DoesNotContain("postgres://", cipher.Data); Assert.DoesNotContain("secret", cipher.Data); } - [Fact] - public void EncryptDecrypt_SshKeyCipher_RoundtripPreservesPlaintext() - { - var orgKeys = RustSdkService.GenerateOrganizationKeys(); - - var originalCipher = new CipherViewDto - { - Name = "Dev Server Key", - Type = CipherTypes.SshKey, - SshKey = new SshKeyViewDto - { - PrivateKey = "-----BEGIN FAKE RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...\n-----END FAKE RSA PRIVATE KEY-----", - PublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user@host", - Fingerprint = "SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8" - } - }; - - var originalJson = JsonSerializer.Serialize(originalCipher, SdkJsonOptions); - var encryptedJson = RustSdkService.EncryptCipher(originalJson, orgKeys.Key); - - Assert.DoesNotContain("\"error\"", encryptedJson); - Assert.DoesNotContain("BEGIN FAKE RSA PRIVATE KEY", encryptedJson); - Assert.DoesNotContain("ssh-rsa AAAAB3", encryptedJson); - - var decryptedJson = RustSdkService.DecryptCipher(encryptedJson, orgKeys.Key); - var decrypted = JsonSerializer.Deserialize(decryptedJson, SdkJsonOptions); - - Assert.NotNull(decrypted?.SshKey); - Assert.Contains("BEGIN FAKE RSA PRIVATE KEY", decrypted.SshKey.PrivateKey); - Assert.StartsWith("ssh-rsa", decrypted.SshKey.PublicKey); - Assert.StartsWith("SHA256:", decrypted.SshKey.Fingerprint); - } - [Fact] public void CipherSeeder_SshKeyCipher_ProducesServerCompatibleFormat() { @@ -477,7 +443,7 @@ public void CipherSeeder_SshKeyCipher_ProducesServerCompatibleFormat() var sshData = JsonSerializer.Deserialize(cipher.Data); Assert.NotNull(sshData); - var encStringPrefix = "2."; + const string encStringPrefix = "2."; Assert.StartsWith(encStringPrefix, sshData.Name); Assert.StartsWith(encStringPrefix, sshData.PrivateKey); Assert.StartsWith(encStringPrefix, sshData.PublicKey); @@ -486,5 +452,4 @@ public void CipherSeeder_SshKeyCipher_ProducesServerCompatibleFormat() Assert.DoesNotContain("BEGIN FAKE OPENSSH PRIVATE KEY", cipher.Data); Assert.DoesNotContain("ssh-ed25519", cipher.Data); } - } diff --git a/util/RustSdk/RustSdkService.cs b/util/RustSdk/RustSdkService.cs index b6ada76df791..790b803470a6 100644 --- a/util/RustSdk/RustSdkService.cs +++ b/util/RustSdk/RustSdkService.cs @@ -78,47 +78,57 @@ public static unsafe string GenerateUserOrganizationKey(string userKey, string o } } - public static unsafe string EncryptCipher(string cipherViewJson, string symmetricKeyBase64) + /// + /// Encrypts a plaintext string using the provided symmetric key. + /// Returns an EncString in format "2.{iv}|{data}|{mac}". + /// + public static unsafe string EncryptString(string plaintext, string symmetricKeyBase64) { - var cipherViewBytes = StringToRustString(cipherViewJson); + var plaintextBytes = StringToRustString(plaintext); var keyBytes = StringToRustString(symmetricKeyBase64); - fixed (byte* cipherViewPtr = cipherViewBytes) + fixed (byte* plaintextPtr = plaintextBytes) fixed (byte* keyPtr = keyBytes) { - var resultPtr = NativeMethods.encrypt_cipher(cipherViewPtr, keyPtr); + var resultPtr = NativeMethods.encrypt_string(plaintextPtr, keyPtr); return ParseResponse(resultPtr); } } - public static unsafe string DecryptCipher(string cipherJson, string symmetricKeyBase64) + /// + /// Decrypts an EncString using the provided symmetric key. + /// + public static unsafe string DecryptString(string encString, string symmetricKeyBase64) { - var cipherBytes = StringToRustString(cipherJson); + var encStringBytes = StringToRustString(encString); var keyBytes = StringToRustString(symmetricKeyBase64); - fixed (byte* cipherPtr = cipherBytes) + fixed (byte* encStringPtr = encStringBytes) fixed (byte* keyPtr = keyBytes) { - var resultPtr = NativeMethods.decrypt_cipher(cipherPtr, keyPtr); + var resultPtr = NativeMethods.decrypt_string(encStringPtr, keyPtr); return ParseResponse(resultPtr); } } /// - /// Encrypts a plaintext string using the provided symmetric key. - /// Returns an EncString in format "2.{iv}|{data}|{mac}". + /// Encrypts specified fields in a JSON object. Field paths use dot notation + /// with [*] for array elements (e.g. "login.uris[*].uri"). + /// Returns the modified JSON with matching string fields encrypted as EncStrings. /// - public static unsafe string EncryptString(string plaintext, string symmetricKeyBase64) + public static unsafe string EncryptFields(string json, string fieldPathsJson, string symmetricKeyBase64) { - var plaintextBytes = StringToRustString(plaintext); + var jsonBytes = StringToRustString(json); + var pathsBytes = StringToRustString(fieldPathsJson); var keyBytes = StringToRustString(symmetricKeyBase64); - fixed (byte* plaintextPtr = plaintextBytes) + fixed (byte* jsonPtr = jsonBytes) + fixed (byte* pathsPtr = pathsBytes) fixed (byte* keyPtr = keyBytes) { - var resultPtr = NativeMethods.encrypt_string(plaintextPtr, keyPtr); + var resultPtr = NativeMethods.encrypt_fields(jsonPtr, pathsPtr, keyPtr); return ParseResponse(resultPtr); } diff --git a/util/RustSdk/rust/Cargo.lock b/util/RustSdk/rust/Cargo.lock index 1b30f91fe563..4485cf2eae8e 100644 --- a/util/RustSdk/rust/Cargo.lock +++ b/util/RustSdk/rust/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aead" version = "0.5.2" @@ -48,33 +33,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - [[package]] name = "argon2" version = "0.6.0-rc.2" @@ -88,44 +46,12 @@ dependencies = [ "zeroize", ] -[[package]] -name = "async-trait" -version = "0.1.88" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "base64" version = "0.22.1" @@ -138,139 +64,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bitflags" version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" -[[package]] -name = "bitwarden-api-api" -version = "2.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=abba7fdab687753268b63248ec22639dff35d07c#abba7fdab687753268b63248ec22639dff35d07c" -dependencies = [ - "async-trait", - "bitwarden-api-base", - "reqwest", - "reqwest-middleware", - "serde", - "serde_json", - "serde_repr", - "serde_with", - "url", - "uuid", -] - -[[package]] -name = "bitwarden-api-base" -version = "2.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=abba7fdab687753268b63248ec22639dff35d07c#abba7fdab687753268b63248ec22639dff35d07c" -dependencies = [ - "reqwest", - "reqwest-middleware", - "serde_json", - "thiserror 1.0.69", - "url", -] - -[[package]] -name = "bitwarden-api-identity" -version = "2.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=abba7fdab687753268b63248ec22639dff35d07c#abba7fdab687753268b63248ec22639dff35d07c" -dependencies = [ - "async-trait", - "bitwarden-api-base", - "reqwest", - "reqwest-middleware", - "serde", - "serde_json", - "serde_repr", - "serde_with", - "url", - "uuid", -] - -[[package]] -name = "bitwarden-api-key-connector" -version = "2.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=abba7fdab687753268b63248ec22639dff35d07c#abba7fdab687753268b63248ec22639dff35d07c" -dependencies = [ - "async-trait", - "bitwarden-api-base", - "mockall", - "reqwest", - "reqwest-middleware", - "serde", - "serde_json", - "tokio", -] - -[[package]] -name = "bitwarden-collections" -version = "2.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=abba7fdab687753268b63248ec22639dff35d07c#abba7fdab687753268b63248ec22639dff35d07c" -dependencies = [ - "bitwarden-api-api", - "bitwarden-core", - "bitwarden-crypto", - "bitwarden-error", - "bitwarden-uuid", - "serde", - "serde_repr", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "bitwarden-core" -version = "2.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=abba7fdab687753268b63248ec22639dff35d07c#abba7fdab687753268b63248ec22639dff35d07c" -dependencies = [ - "async-trait", - "bitwarden-api-api", - "bitwarden-api-base", - "bitwarden-api-identity", - "bitwarden-api-key-connector", - "bitwarden-crypto", - "bitwarden-encoding", - "bitwarden-error", - "bitwarden-state", - "bitwarden-uuid", - "chrono", - "getrandom 0.2.16", - "rand 0.8.5", - "reqwest", - "reqwest-middleware", - "rustls", - "rustls-platform-verifier", - "schemars 1.0.4", - "serde", - "serde_bytes", - "serde_json", - "serde_qs", - "serde_repr", - "thiserror 1.0.69", - "tracing", - "uuid", - "zeroize", - "zxcvbn", -] - [[package]] name = "bitwarden-crypto" version = "2.0.0" @@ -291,18 +90,18 @@ dependencies = [ "num-bigint", "num-traits", "pbkdf2", - "rand 0.8.5", - "rand_chacha 0.3.1", + "rand", + "rand_chacha", "rayon", "rsa", - "schemars 1.0.4", + "schemars", "serde", "serde_bytes", "serde_repr", "sha1", "sha2", "subtle", - "thiserror 1.0.69", + "thiserror", "tracing", "typenum", "uuid", @@ -318,7 +117,7 @@ dependencies = [ "data-encoding", "data-encoding-macro", "serde", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -340,85 +139,6 @@ dependencies = [ "syn", ] -[[package]] -name = "bitwarden-state" -version = "2.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=abba7fdab687753268b63248ec22639dff35d07c#abba7fdab687753268b63248ec22639dff35d07c" -dependencies = [ - "async-trait", - "bitwarden-error", - "bitwarden-threading", - "indexed-db", - "js-sys", - "rusqlite", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tsify", -] - -[[package]] -name = "bitwarden-threading" -version = "2.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=abba7fdab687753268b63248ec22639dff35d07c#abba7fdab687753268b63248ec22639dff35d07c" -dependencies = [ - "bitwarden-error", - "serde", - "thiserror 1.0.69", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "bitwarden-uuid" -version = "2.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=abba7fdab687753268b63248ec22639dff35d07c#abba7fdab687753268b63248ec22639dff35d07c" -dependencies = [ - "bitwarden-uuid-macro", -] - -[[package]] -name = "bitwarden-uuid-macro" -version = "2.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=abba7fdab687753268b63248ec22639dff35d07c#abba7fdab687753268b63248ec22639dff35d07c" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "bitwarden-vault" -version = "2.0.0" -source = "git+https://github.com/bitwarden/sdk-internal.git?rev=abba7fdab687753268b63248ec22639dff35d07c#abba7fdab687753268b63248ec22639dff35d07c" -dependencies = [ - "bitwarden-api-api", - "bitwarden-collections", - "bitwarden-core", - "bitwarden-crypto", - "bitwarden-encoding", - "bitwarden-error", - "bitwarden-state", - "bitwarden-uuid", - "chrono", - "data-encoding", - "futures", - "hmac", - "percent-encoding", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "sha1", - "sha2", - "subtle", - "thiserror 1.0.69", - "tracing", - "uuid", - "zxcvbn", -] - [[package]] name = "blake2" version = "0.11.0-rc.3" @@ -467,12 +187,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "bytes" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - [[package]] name = "cbc" version = "0.1.2" @@ -482,33 +196,12 @@ dependencies = [ "cipher", ] -[[package]] -name = "cc" -version = "1.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" -dependencies = [ - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chacha20" version = "0.9.1" @@ -539,13 +232,7 @@ version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ - "android-tzdata", - "iana-time-zone", - "js-sys", "num-traits", - "serde", - "wasm-bindgen", - "windows-link", ] [[package]] @@ -586,38 +273,12 @@ dependencies = [ "zeroize", ] -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "coset" version = "0.4.1" @@ -797,47 +458,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "deranged" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" -dependencies = [ - "powerfmt", - "serde", -] - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn", -] - [[package]] name = "digest" version = "0.10.7" @@ -861,23 +481,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "downcast" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" - [[package]] name = "dyn-clone" version = "1.0.19" @@ -915,35 +518,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fancy-regex" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fiat-crypto" version = "0.2.9" @@ -957,118 +531,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fragile" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ @@ -1084,10 +548,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "wasm-bindgen", ] [[package]] @@ -1097,36 +559,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", - "wasm-bindgen", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "h2" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.10.0", - "slab", - "tokio", - "tokio-util", - "tracing", ] [[package]] @@ -1139,36 +574,6 @@ dependencies = [ "crunchy", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.4", -] - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "hkdf" version = "0.12.4" @@ -1187,46 +592,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "http" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - [[package]] name = "hybrid-array" version = "0.4.5" @@ -1236,239 +601,12 @@ dependencies = [ "typenum", ] -[[package]] -name = "hyper" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" - -[[package]] -name = "icu_properties" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "potential_utf", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" - -[[package]] -name = "icu_provider" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" -dependencies = [ - "displaydoc", - "icu_locale_core", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" -[[package]] -name = "idna" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexed-db" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78f4ecbb6cd50773303683617a93fc2782267d2c94546e9545ec4190eb69aa1a" -dependencies = [ - "futures-channel", - "futures-util", - "pin-project-lite", - "scoped-tls", - "thiserror 2.0.12", - "web-sys", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" -dependencies = [ - "equivalent", - "hashbrown 0.15.4", - "serde", -] - [[package]] name = "inout" version = "0.1.4" @@ -1479,59 +617,12 @@ dependencies = [ "generic-array", ] -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - [[package]] name = "js-sys" version = "0.3.77" @@ -1563,103 +654,18 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" -[[package]] -name = "libsqlite3-sys" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "litemap" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" - [[package]] name = "log" version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "memchr" version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - -[[package]] -name = "mio" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" -dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", -] - -[[package]] -name = "mockall" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" -dependencies = [ - "cfg-if", - "downcast", - "fragile", - "mockall_derive", - "predicates", - "predicates-tree", -] - -[[package]] -name = "mockall_derive" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" -dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "num-bigint" version = "0.4.6" @@ -1682,17 +688,11 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand", "smallvec", "zeroize", ] -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - [[package]] name = "num-integer" version = "0.1.46" @@ -1723,15 +723,6 @@ dependencies = [ "libm", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -1744,12 +735,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "password-hash" version = "0.6.0-rc.2" @@ -1779,24 +764,12 @@ dependencies = [ "base64ct", ] -[[package]] -name = "percent-encoding" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" - [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkcs1" version = "0.7.5" @@ -1818,12 +791,6 @@ dependencies = [ "spki", ] -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - [[package]] name = "poly1305" version = "0.8.0" @@ -1835,21 +802,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "potential_utf" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1859,32 +811,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "predicates" -version = "3.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" -dependencies = [ - "anstyle", - "predicates-core", -] - -[[package]] -name = "predicates-core" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" - -[[package]] -name = "predicates-tree" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" -dependencies = [ - "predicates-core", - "termtree", -] - [[package]] name = "proc-macro2" version = "1.0.95" @@ -1894,61 +820,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quinn" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.12", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" -dependencies = [ - "bytes", - "getrandom 0.3.3", - "lru-slab", - "rand 0.9.1", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.12", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.59.0", -] - [[package]] name = "quote" version = "1.0.40" @@ -1971,20 +842,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] -[[package]] -name = "rand" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.3", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -1995,16 +856,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.3", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -2014,15 +865,6 @@ dependencies = [ "getrandom 0.2.16", ] -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.3", -] - [[package]] name = "rand_core" version = "0.10.0-rc-2" @@ -2066,234 +908,65 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn", -] - -[[package]] -name = "regex" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" - -[[package]] -name = "reqwest" -version = "0.12.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" -dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "reqwest-middleware" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" -dependencies = [ - "anyhow", - "async-trait", - "http", - "reqwest", - "serde", - "thiserror 1.0.69", - "tower-service", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.16", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rsa" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" -dependencies = [ - "const-oid", - "digest 0.10.7", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rusqlite" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" -dependencies = [ - "bitflags", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", + "syn", ] [[package]] -name = "rustls" -version = "0.23.28" +name = "regex" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] -name = "rustls-native-certs" -version = "0.8.1" +name = "regex-automata" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", + "aho-corasick", + "memchr", + "regex-syntax", ] [[package]] -name = "rustls-pki-types" -version = "1.12.0" +name = "regex-syntax" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" -dependencies = [ - "web-time", - "zeroize", -] +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] -name = "rustls-platform-verifier" -version = "0.6.0" +name = "rsa" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda84358ed17f1f354cf4b1909ad346e6c7bc2513e8c40eb08e0157aa13a9070" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.59.0", + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", ] [[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.3" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", + "semver", ] [[package]] @@ -2308,36 +981,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "schemars" version = "1.0.4" @@ -2365,48 +1008,17 @@ dependencies = [ "syn", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "sdk" version = "0.1.0" dependencies = [ "base64", - "bitwarden-core", "bitwarden-crypto", - "bitwarden-vault", "csbindgen", "serde", "serde_json", ] -[[package]] -name = "security-framework" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.26" @@ -2422,17 +1034,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-wasm-bindgen" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" -dependencies = [ - "js-sys", - "serde", - "wasm-bindgen", -] - [[package]] name = "serde_bytes" version = "0.11.17" @@ -2476,17 +1077,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_qs" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" -dependencies = [ - "percent-encoding", - "serde", - "thiserror 2.0.12", -] - [[package]] name = "serde_repr" version = "0.1.20" @@ -2498,49 +1088,6 @@ dependencies = [ "syn", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_with" -version = "3.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf65a400f8f66fb7b0552869ad70157166676db75ed8181f8104ea91cf9d0b42" -dependencies = [ - "base64", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.10.0", - "schemars 0.9.0", - "serde", - "serde_derive", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81679d9ed988d5e9a5e6531dc3f2c28efbd639cbd1dfb628df08edea6004da77" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "sha1" version = "0.10.6" @@ -2563,12 +1110,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "signature" version = "2.2.0" @@ -2579,28 +1120,12 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "slab" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" - [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "spin" version = "0.9.8" @@ -2617,12 +1142,6 @@ dependencies = [ "der", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - [[package]] name = "strsim" version = "0.11.1" @@ -2646,48 +1165,13 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "termtree" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" - [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" -dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl", ] [[package]] @@ -2695,173 +1179,11 @@ name = "thiserror-impl" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "time" -version = "0.3.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" - -[[package]] -name = "time-macros" -version = "0.2.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.45.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" -dependencies = [ - "backtrace", - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.52.0", -] - -[[package]] -name = "tokio-macros" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "tracing" @@ -2894,48 +1216,12 @@ dependencies = [ "once_cell", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "tsify" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ec91b85e6c6592ed28636cb1dd1fac377ecbbeb170ff1d79f97aac5e38926d" -dependencies = [ - "serde", - "serde-wasm-bindgen", - "tsify-macros", - "wasm-bindgen", -] - -[[package]] -name = "tsify-macros" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a324606929ad11628a19206d7853807481dcaecd6c08be70a235930b8241955" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn", -] - [[package]] name = "typenum" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" -[[package]] -name = "unicase" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" - [[package]] name = "unicode-ident" version = "1.0.18" @@ -2952,29 +1238,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "uuid" version = "1.17.0" @@ -2987,37 +1250,12 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3059,19 +1297,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" -dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.100" @@ -3104,251 +1329,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "web-sys" -version = "0.3.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86138b15b2b7d561bc4469e77027b8dd005a43dc502e9031d1f5afc8ce1f280e" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "winapi-util" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -3358,36 +1338,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "writeable" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" - -[[package]] -name = "yoke" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.8.26" @@ -3408,27 +1358,6 @@ dependencies = [ "syn", ] -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - [[package]] name = "zeroize" version = "1.8.1" @@ -3454,53 +1383,3 @@ name = "zeroizing-alloc" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebff5e6b81c1c7dca2d0bd333b2006da48cb37dbcae5a8da888f31fcb3c19934" - -[[package]] -name = "zerotrie" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zxcvbn" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad76e35b00ad53688d6b90c431cabe3cbf51f7a4a154739e04b63004ab1c736c" -dependencies = [ - "chrono", - "derive_builder", - "fancy-regex", - "itertools", - "lazy_static", - "regex", - "time", - "wasm-bindgen", - "web-sys", -] diff --git a/util/RustSdk/rust/Cargo.toml b/util/RustSdk/rust/Cargo.toml index 81ebc1115000..efbcd91c4c28 100644 --- a/util/RustSdk/rust/Cargo.toml +++ b/util/RustSdk/rust/Cargo.toml @@ -13,9 +13,7 @@ crate-type = ["cdylib"] [dependencies] base64 = "0.22.1" -bitwarden-core = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "abba7fdab687753268b63248ec22639dff35d07c", features = ["internal"] } bitwarden-crypto = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "abba7fdab687753268b63248ec22639dff35d07c" } -bitwarden-vault = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "abba7fdab687753268b63248ec22639dff35d07c" } serde = "=1.0.219" serde_json = "=1.0.141" diff --git a/util/RustSdk/rust/src/cipher.rs b/util/RustSdk/rust/src/cipher.rs index b6e53dd79710..3ec0fb3ead10 100644 --- a/util/RustSdk/rust/src/cipher.rs +++ b/util/RustSdk/rust/src/cipher.rs @@ -1,18 +1,16 @@ -//! Cipher encryption and decryption functions for the Seeder. +//! Field-level encryption functions for the Seeder. //! -//! This module provides FFI functions for encrypting and decrypting Bitwarden ciphers -//! using the Rust SDK's cryptographic primitives. +//! This module provides FFI functions for encrypting and decrypting individual string +//! values and JSON fields using AES-256-CBC-HMAC-SHA256 via bitwarden_crypto. +//! No dependency on bitwarden_vault types — the caller drives which fields to encrypt. use std::ffi::{c_char, CStr, CString}; use base64::{engine::general_purpose::STANDARD, Engine}; -use bitwarden_core::key_management::KeyIds; use bitwarden_crypto::{ - BitwardenLegacyKeyBytes, CompositeEncryptable, Decryptable, KeyEncryptable, KeyStore, - SymmetricCryptoKey, + BitwardenLegacyKeyBytes, EncString, KeyDecryptable, KeyEncryptable, SymmetricCryptoKey, }; -use bitwarden_vault::{Cipher, CipherView}; /// Create an error JSON response and return it as a C string pointer. fn error_response(message: &str) -> *const c_char { @@ -20,34 +18,30 @@ fn error_response(message: &str) -> *const c_char { CString::new(error_json).unwrap().into_raw() } -/// Encrypt a CipherView with a symmetric key, returning an encrypted Cipher as JSON. +/// Encrypt a plaintext string with a symmetric key, returning an EncString. /// /// # Arguments -/// * `cipher_view_json` - JSON string representing a CipherView (camelCase format) +/// * `plaintext` - The plaintext string to encrypt /// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256) /// /// # Returns -/// JSON string representing the encrypted Cipher +/// EncString in format "2.{iv}|{data}|{mac}" /// /// # Safety /// Both pointers must be valid null-terminated strings. #[no_mangle] -pub unsafe extern "C" fn encrypt_cipher( - cipher_view_json: *const c_char, +pub unsafe extern "C" fn encrypt_string( + plaintext: *const c_char, symmetric_key_b64: *const c_char, ) -> *const c_char { - let Ok(cipher_view_json) = CStr::from_ptr(cipher_view_json).to_str() else { - return error_response("Invalid UTF-8 in cipher_view_json"); + let Ok(plaintext) = CStr::from_ptr(plaintext).to_str() else { + return error_response("Invalid UTF-8 in plaintext"); }; let Ok(key_b64) = CStr::from_ptr(symmetric_key_b64).to_str() else { return error_response("Invalid UTF-8 in symmetric_key_b64"); }; - let Ok(cipher_view): Result = serde_json::from_str(cipher_view_json) else { - return error_response("Failed to parse CipherView JSON"); - }; - let Ok(key_bytes) = STANDARD.decode(key_b64) else { return error_response("Failed to decode base64 key"); }; @@ -56,46 +50,39 @@ pub unsafe extern "C" fn encrypt_cipher( return error_response("Failed to create symmetric key: invalid key format or length"); }; - let store: KeyStore = KeyStore::default(); - let mut ctx = store.context_mut(); - let key_id = ctx.add_local_symmetric_key(key); - - let Ok(cipher) = cipher_view.encrypt_composite(&mut ctx, key_id) else { - return error_response("Failed to encrypt cipher: encryption operation failed"); + let Ok(encrypted) = plaintext.to_string().encrypt_with_key(&key) else { + return error_response("Failed to encrypt string"); }; - match serde_json::to_string(&cipher) { - Ok(json) => CString::new(json).unwrap().into_raw(), - Err(_) => error_response("Failed to serialize encrypted cipher"), - } + CString::new(encrypted.to_string()).unwrap().into_raw() } -/// Decrypt an encrypted Cipher with a symmetric key, returning a CipherView as JSON. +/// Decrypt an EncString with a symmetric key, returning the plaintext. /// /// # Arguments -/// * `cipher_json` - JSON string representing an encrypted Cipher +/// * `enc_string` - EncString in format "2.{iv}|{data}|{mac}" /// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256) /// /// # Returns -/// JSON string representing the decrypted CipherView +/// The decrypted plaintext string /// /// # Safety /// Both pointers must be valid null-terminated strings. #[no_mangle] -pub unsafe extern "C" fn decrypt_cipher( - cipher_json: *const c_char, +pub unsafe extern "C" fn decrypt_string( + enc_string: *const c_char, symmetric_key_b64: *const c_char, ) -> *const c_char { - let Ok(cipher_json) = CStr::from_ptr(cipher_json).to_str() else { - return error_response("Invalid UTF-8 in cipher_json"); + let Ok(enc_str) = CStr::from_ptr(enc_string).to_str() else { + return error_response("Invalid UTF-8 in enc_string"); }; let Ok(key_b64) = CStr::from_ptr(symmetric_key_b64).to_str() else { return error_response("Invalid UTF-8 in symmetric_key_b64"); }; - let Ok(cipher): Result = serde_json::from_str(cipher_json) else { - return error_response("Failed to parse Cipher JSON"); + let Ok(parsed): Result = enc_str.parse() else { + return error_response("Failed to parse EncString"); }; let Ok(key_bytes) = STANDARD.decode(key_b64) else { @@ -106,44 +93,55 @@ pub unsafe extern "C" fn decrypt_cipher( return error_response("Failed to create symmetric key: invalid key format or length"); }; - let store: KeyStore = KeyStore::default(); - let mut ctx = store.context_mut(); - let key_id = ctx.add_local_symmetric_key(key); - - let Ok(cipher_view): Result = cipher.decrypt(&mut ctx, key_id) else { - return error_response("Failed to decrypt cipher: decryption operation failed"); + let Ok(plaintext): Result = parsed.decrypt_with_key(&key) else { + return error_response("Failed to decrypt string"); }; - match serde_json::to_string(&cipher_view) { - Ok(json) => CString::new(json).unwrap().into_raw(), - Err(_) => error_response("Failed to serialize decrypted cipher"), - } + CString::new(plaintext).unwrap().into_raw() } -/// Encrypt a plaintext string with a symmetric key, returning an EncString. +/// Encrypt specified fields in a JSON object, returning the modified JSON. +/// +/// Takes a JSON object, a JSON array of dot-notation field paths (with `[*]` for +/// array elements), and a symmetric key. Walks the JSON tree and encrypts string +/// values at matching paths. Non-string values and unmatched paths are left unchanged. /// /// # Arguments -/// * `plaintext` - The plaintext string to encrypt -/// * `symmetric_key_b64` - Base64-encoded symmetric key (64 bytes for AES-256-CBC-HMAC-SHA256) +/// * `json` - JSON object string +/// * `field_paths_json` - JSON array of path strings, e.g. `["name","login.username","login.uris[*].uri"]` +/// * `symmetric_key_b64` - Base64-encoded symmetric key /// /// # Returns -/// EncString in format "2.{iv}|{data}|{mac}" +/// Modified JSON with matching string fields encrypted as EncStrings /// /// # Safety -/// Both pointers must be valid null-terminated strings. +/// All pointers must be valid null-terminated strings. #[no_mangle] -pub unsafe extern "C" fn encrypt_string( - plaintext: *const c_char, +pub unsafe extern "C" fn encrypt_fields( + json: *const c_char, + field_paths_json: *const c_char, symmetric_key_b64: *const c_char, ) -> *const c_char { - let Ok(plaintext) = CStr::from_ptr(plaintext).to_str() else { - return error_response("Invalid UTF-8 in plaintext"); + let Ok(json_str) = CStr::from_ptr(json).to_str() else { + return error_response("Invalid UTF-8 in json"); + }; + + let Ok(paths_str) = CStr::from_ptr(field_paths_json).to_str() else { + return error_response("Invalid UTF-8 in field_paths_json"); }; let Ok(key_b64) = CStr::from_ptr(symmetric_key_b64).to_str() else { return error_response("Invalid UTF-8 in symmetric_key_b64"); }; + let Ok(mut value): Result = serde_json::from_str(json_str) else { + return error_response("Failed to parse JSON"); + }; + + let Ok(paths): Result, _> = serde_json::from_str(paths_str) else { + return error_response("Failed to parse field paths JSON"); + }; + let Ok(key_bytes) = STANDARD.decode(key_b64) else { return error_response("Failed to decode base64 key"); }; @@ -152,253 +150,222 @@ pub unsafe extern "C" fn encrypt_string( return error_response("Failed to create symmetric key: invalid key format or length"); }; - let Ok(encrypted) = plaintext.to_string().encrypt_with_key(&key) else { - return error_response("Failed to encrypt string"); - }; + for path in &paths { + if let Err(msg) = encrypt_at_path(&mut value, path, &key) { + return error_response(&msg); + } + } - CString::new(encrypted.to_string()).unwrap().into_raw() + match serde_json::to_string(&value) { + Ok(result) => CString::new(result).unwrap().into_raw(), + Err(_) => error_response("Failed to serialize result JSON"), + } } -#[cfg(test)] -mod tests { - use super::*; - use crate::{free_c_string, generate_organization_keys}; - use bitwarden_vault::{CipherType, LoginView}; - - fn create_test_cipher_view() -> CipherView { - CipherView { - id: None, - organization_id: None, - folder_id: None, - collection_ids: vec![], - key: None, - name: "Test Login".to_string(), - notes: Some("Secret notes".to_string()), - r#type: CipherType::Login, - login: Some(LoginView { - username: Some("testuser@example.com".to_string()), - password: Some("SuperSecretP@ssw0rd!".to_string()), - password_revision_date: None, - uris: None, - totp: None, - autofill_on_page_load: None, - fido2_credentials: None, - }), - identity: None, - card: None, - secure_note: None, - ssh_key: None, - favorite: false, - reprompt: bitwarden_vault::CipherRepromptType::None, - organization_use_totp: false, - edit: true, - permissions: None, - view_password: true, - local_data: None, - attachments: None, - attachment_decryption_failures: None, - fields: None, - password_history: None, - creation_date: "2025-01-01T00:00:00Z".parse().unwrap(), - deleted_date: None, - revision_date: "2025-01-01T00:00:00Z".parse().unwrap(), - archived_date: None, - } +/// Walks a JSON value tree and encrypts string values at the given dot-path. +/// Supports `[*]` segments for iterating array elements. +fn encrypt_at_path( + value: &mut serde_json::Value, + path: &str, + key: &SymmetricCryptoKey, +) -> Result<(), String> { + let segments: Vec<&str> = path.split('.').collect(); + encrypt_segments(value, &segments, key) +} + +fn encrypt_segments( + value: &mut serde_json::Value, + segments: &[&str], + key: &SymmetricCryptoKey, +) -> Result<(), String> { + if segments.is_empty() { + return Ok(()); } - fn call_encrypt_cipher(cipher_json: &str, key_b64: &str) -> String { - let cipher_cstr = CString::new(cipher_json).unwrap(); - let key_cstr = CString::new(key_b64).unwrap(); + let segment = segments[0]; + let rest = &segments[1..]; - let result_ptr = unsafe { encrypt_cipher(cipher_cstr.as_ptr(), key_cstr.as_ptr()) }; - let result_cstr = unsafe { CStr::from_ptr(result_ptr) }; - let result = result_cstr.to_str().unwrap().to_owned(); - unsafe { free_c_string(result_ptr as *mut c_char) }; + // Handle array wildcard: "uris[*]" means iterate all elements of the "uris" array + if let Some(field_name) = segment.strip_suffix("[*]") { + let Some(arr) = value.get_mut(field_name).and_then(|v| v.as_array_mut()) else { + return Ok(()); // Field missing or not an array — skip + }; - result + for element in arr.iter_mut() { + encrypt_segments(element, rest, key)?; + } + + return Ok(()); } - fn make_test_key_b64() -> String { + // Last segment — encrypt the value if it's a string + if rest.is_empty() { + if let Some(s) = value.get(segment).and_then(|v| v.as_str()) { + let encrypted = s + .to_string() + .encrypt_with_key(key) + .map_err(|_| format!("Failed to encrypt field '{segment}'"))?; + value[segment] = serde_json::Value::String(encrypted.to_string()); + } + // null or missing — leave unchanged + return Ok(()); + } + + // Intermediate segment — recurse into nested object + let Some(nested) = value.get_mut(segment) else { + return Ok(()); // Field missing — skip + }; + + encrypt_segments(nested, rest, key) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::free_c_string; + + fn make_test_key() -> SymmetricCryptoKey { SymmetricCryptoKey::make_aes256_cbc_hmac_key() - .to_base64() - .into() } - #[test] - fn encrypt_cipher_produces_encrypted_fields() { - let key_b64 = make_test_key_b64(); - let cipher_view = create_test_cipher_view(); - let cipher_json = serde_json::to_string(&cipher_view).unwrap(); - - let encrypted_json = call_encrypt_cipher(&cipher_json, &key_b64); - - assert!( - !encrypted_json.contains("\"error\""), - "Got error: {}", - encrypted_json - ); - - let encrypted_cipher: Cipher = - serde_json::from_str(&encrypted_json).expect("Failed to parse encrypted cipher JSON"); - - let encrypted_name = encrypted_cipher.name.to_string(); - assert!( - encrypted_name.starts_with("2."), - "Name should be encrypted: {}", - encrypted_name - ); - - let login = encrypted_cipher.login.expect("Login should be present"); - if let Some(username) = &login.username { - assert!( - username.to_string().starts_with("2."), - "Username should be encrypted" - ); - } - if let Some(password) = &login.password { - assert!( - password.to_string().starts_with("2."), - "Password should be encrypted" - ); - } + fn call_ffi_string(func: unsafe extern "C" fn(*const c_char, *const c_char) -> *const c_char, a: &str, b: &str) -> String { + let a_cstr = CString::new(a).unwrap(); + let b_cstr = CString::new(b).unwrap(); + let ptr = unsafe { func(a_cstr.as_ptr(), b_cstr.as_ptr()) }; + let result = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_owned(); + unsafe { free_c_string(ptr as *mut c_char) }; + result } #[test] - fn encrypt_cipher_works_with_generated_org_key() { - let org_keys_ptr = unsafe { generate_organization_keys() }; - let org_keys_cstr = unsafe { CStr::from_ptr(org_keys_ptr) }; - let org_keys_json = org_keys_cstr.to_str().unwrap().to_owned(); - unsafe { free_c_string(org_keys_ptr as *mut c_char) }; + fn encrypt_string_decrypt_string_roundtrip() { + let key = make_test_key(); + let key_b64: String = key.to_base64().into(); - let org_keys: serde_json::Value = serde_json::from_str(&org_keys_json).unwrap(); - let org_key_b64 = org_keys["key"].as_str().unwrap(); + let encrypted = call_ffi_string(encrypt_string, "hello world", &key_b64); + assert!(encrypted.starts_with("2."), "Expected EncString, got: {encrypted}"); - let cipher_view = create_test_cipher_view(); - let cipher_json = serde_json::to_string(&cipher_view).unwrap(); + let decrypted = call_ffi_string(decrypt_string, &encrypted, &key_b64); + assert_eq!(decrypted, "hello world"); + } - let encrypted_json = call_encrypt_cipher(&cipher_json, org_key_b64); + #[test] + fn encrypt_at_path_encrypts_top_level_string() { + let key = make_test_key(); + let mut value: serde_json::Value = serde_json::json!({"name": "Test", "type": 1}); - assert!( - !encrypted_json.contains("\"error\""), - "Got error: {}", - encrypted_json - ); + encrypt_at_path(&mut value, "name", &key).unwrap(); - let encrypted_cipher: Cipher = serde_json::from_str(&encrypted_json).unwrap(); - assert!(encrypted_cipher.name.to_string().starts_with("2.")); + let name = value["name"].as_str().unwrap(); + assert!(name.starts_with("2."), "Expected encrypted, got: {name}"); + assert_ne!(name, "Test"); } #[test] - fn encrypt_cipher_rejects_invalid_json() { - let key_b64 = make_test_key_b64(); + fn encrypt_at_path_encrypts_nested_field() { + let key = make_test_key(); + let mut value: serde_json::Value = serde_json::json!({ + "login": {"username": "user@test.com", "password": "secret"} + }); + + encrypt_at_path(&mut value, "login.username", &key).unwrap(); - let error_json = call_encrypt_cipher("{ this is not valid json }", &key_b64); + let username = value["login"]["username"].as_str().unwrap(); + assert!(username.starts_with("2."), "Expected encrypted, got: {username}"); - assert!( - error_json.contains("\"error\""), - "Should return error for invalid JSON" - ); - assert!(error_json.contains("Failed to parse CipherView JSON")); + // password should be unchanged + assert_eq!(value["login"]["password"].as_str().unwrap(), "secret"); } #[test] - fn encrypt_cipher_rejects_invalid_base64_key() { - let cipher_view = create_test_cipher_view(); - let cipher_json = serde_json::to_string(&cipher_view).unwrap(); + fn encrypt_at_path_encrypts_array_wildcard() { + let key = make_test_key(); + let mut value: serde_json::Value = serde_json::json!({ + "login": { + "uris": [ + {"uri": "https://example.com", "match": 0}, + {"uri": "https://test.com", "match": 1} + ] + } + }); - let error_json = call_encrypt_cipher(&cipher_json, "not-valid-base64!!!"); + encrypt_at_path(&mut value, "login.uris[*].uri", &key).unwrap(); - assert!( - error_json.contains("\"error\""), - "Should return error for invalid base64" - ); - assert!(error_json.contains("Failed to decode base64 key")); + let uris = value["login"]["uris"].as_array().unwrap(); + for uri_obj in uris { + let uri = uri_obj["uri"].as_str().unwrap(); + assert!(uri.starts_with("2."), "Expected encrypted URI, got: {uri}"); + } + // match should be unchanged + assert_eq!(uris[0]["match"].as_i64().unwrap(), 0); } #[test] - fn encrypt_cipher_rejects_wrong_key_length() { - let cipher_view = create_test_cipher_view(); - let cipher_json = serde_json::to_string(&cipher_view).unwrap(); - let short_key_b64 = STANDARD.encode(b"too short"); - - let error_json = call_encrypt_cipher(&cipher_json, &short_key_b64); - - assert!( - error_json.contains("\"error\""), - "Should return error for wrong key length" - ); - assert!(error_json.contains("invalid key format or length")); + fn encrypt_fields_ffi_encrypts_specified_paths() { + let key = make_test_key(); + let key_b64: String = key.to_base64().into(); + + let input_json = serde_json::json!({ + "name": "Test Login", + "type": 1, + "login": {"username": "user@test.com", "password": "secret"} + }).to_string(); + + let paths_json = r#"["name","login.username","login.password"]"#; + + let json_cstr = CString::new(input_json).unwrap(); + let paths_cstr = CString::new(paths_json).unwrap(); + let key_cstr = CString::new(key_b64.as_str()).unwrap(); + + let ptr = unsafe { + encrypt_fields(json_cstr.as_ptr(), paths_cstr.as_ptr(), key_cstr.as_ptr()) + }; + let result = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_owned(); + unsafe { free_c_string(ptr as *mut c_char) }; + + assert!(!result.contains("\"error\""), "Got error: {result}"); + + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + let name = parsed["name"].as_str().unwrap(); + assert!(name.starts_with("2."), "name should be encrypted, got: {name}"); + + let username = parsed["login"]["username"].as_str().unwrap(); + assert!(username.starts_with("2."), "username should be encrypted, got: {username}"); + + // type should be unchanged + assert_eq!(parsed["type"].as_i64().unwrap(), 1); } - fn call_decrypt_cipher(cipher_json: &str, key_b64: &str) -> String { - let cipher_cstr = CString::new(cipher_json).unwrap(); - let key_cstr = CString::new(key_b64).unwrap(); + #[test] + fn decrypt_string_with_wrong_key_fails() { + let key1 = make_test_key(); + let key2 = make_test_key(); + let key1_b64: String = key1.to_base64().into(); + let key2_b64: String = key2.to_base64().into(); - let result_ptr = unsafe { decrypt_cipher(cipher_cstr.as_ptr(), key_cstr.as_ptr()) }; - let result_cstr = unsafe { CStr::from_ptr(result_ptr) }; - let result = result_cstr.to_str().unwrap().to_owned(); - unsafe { free_c_string(result_ptr as *mut c_char) }; + let encrypted = call_ffi_string(encrypt_string, "secret", &key1_b64); + let result = call_ffi_string(decrypt_string, &encrypted, &key2_b64); - result + assert!(result.contains("\"error\""), "Should fail with wrong key, got: {result}"); } #[test] - fn encrypt_decrypt_roundtrip_preserves_plaintext() { - let key_b64 = make_test_key_b64(); - let original_view = create_test_cipher_view(); - let original_json = serde_json::to_string(&original_view).unwrap(); - - let encrypted_json = call_encrypt_cipher(&original_json, &key_b64); - assert!( - !encrypted_json.contains("\"error\""), - "Encryption failed: {}", - encrypted_json - ); - - let decrypted_json = call_decrypt_cipher(&encrypted_json, &key_b64); - assert!( - !decrypted_json.contains("\"error\""), - "Decryption failed: {}", - decrypted_json - ); - - let decrypted_view: CipherView = serde_json::from_str(&decrypted_json) - .expect("Failed to parse decrypted CipherView"); - - assert_eq!(decrypted_view.name, original_view.name); - assert_eq!(decrypted_view.notes, original_view.notes); - - let original_login = original_view.login.expect("Original should have login"); - let decrypted_login = decrypted_view.login.expect("Decrypted should have login"); - - assert_eq!(decrypted_login.username, original_login.username); - assert_eq!(decrypted_login.password, original_login.password); + fn encrypt_at_path_skips_null_values() { + let key = make_test_key(); + let mut value: serde_json::Value = serde_json::json!({"name": null, "type": 1}); + + encrypt_at_path(&mut value, "name", &key).unwrap(); + + assert!(value["name"].is_null(), "Null should remain null"); } #[test] - fn decrypt_cipher_rejects_wrong_key() { - let encrypt_key = make_test_key_b64(); - let wrong_key = make_test_key_b64(); - - let original_view = create_test_cipher_view(); - let original_json = serde_json::to_string(&original_view).unwrap(); - - let encrypted_json = call_encrypt_cipher(&original_json, &encrypt_key); - assert!(!encrypted_json.contains("\"error\"")); - - let decrypted_json = call_decrypt_cipher(&encrypted_json, &wrong_key); - - // Decryption with wrong key should fail or produce garbage - // The SDK may return an error or the MAC validation will fail - let result: Result = serde_json::from_str(&decrypted_json); - if !decrypted_json.contains("\"error\"") { - // If no error, the decrypted data should not match original - if let Ok(view) = result { - assert_ne!( - view.name, original_view.name, - "Decryption with wrong key should not produce original plaintext" - ); - } - } + fn encrypt_at_path_skips_missing_fields() { + let key = make_test_key(); + let mut value: serde_json::Value = serde_json::json!({"type": 1}); + + // Should not error on missing "name" + encrypt_at_path(&mut value, "name", &key).unwrap(); + encrypt_at_path(&mut value, "login.username", &key).unwrap(); } } diff --git a/util/Seeder/Attributes/EncryptPropertyAttribute.cs b/util/Seeder/Attributes/EncryptPropertyAttribute.cs new file mode 100644 index 000000000000..2e4edfda5d89 --- /dev/null +++ b/util/Seeder/Attributes/EncryptPropertyAttribute.cs @@ -0,0 +1,91 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Text.Json.Serialization; + +namespace Bit.Seeder.Attributes; + +/// +/// Marks a string property as Vault Data that must be encrypted. +/// Call to discover all marked field paths for a type, +/// using dot notation with [*] for list elements (e.g. "login.uris[*].uri"). +/// Paths are derived from [JsonPropertyName] and cached per root type. +/// +[AttributeUsage(AttributeTargets.Property)] +internal sealed class EncryptPropertyAttribute : Attribute +{ + private static readonly ConcurrentDictionary Cache = new(); + + internal static string[] GetFieldPaths() => GetFieldPaths(typeof(T)); + + internal static string[] GetFieldPaths(Type rootType) + { + return Cache.GetOrAdd(rootType, static type => + { + var paths = new List(); + CollectPaths(type, prefix: "", paths, visited: []); + return paths.ToArray(); + }); + } + + private static void CollectPaths(Type type, string prefix, List paths, HashSet visited) + { + if (!visited.Add(type)) + { + return; + } + + var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in props) + { + if (!prop.CanRead) + { + continue; + } + + var jsonName = prop.GetCustomAttribute()?.Name ?? prop.Name; + var fullPath = string.IsNullOrEmpty(prefix) ? jsonName : $"{prefix}.{jsonName}"; + + if (prop.GetCustomAttribute() is not null + && prop.PropertyType == typeof(string)) + { + paths.Add(fullPath); + continue; + } + + var propType = prop.PropertyType; + var underlyingType = Nullable.GetUnderlyingType(propType) ?? propType; + + if (IsListOf(underlyingType, out var elementType) + && elementType is not null + && elementType.IsClass + && elementType != typeof(string) + && elementType != typeof(object)) + { + CollectPaths(elementType, $"{fullPath}[*]", paths, visited); + } + else if (underlyingType.IsClass + && underlyingType != typeof(string) + && underlyingType != typeof(object)) + { + CollectPaths(underlyingType, fullPath, paths, visited); + } + } + + visited.Remove(type); + } + + private static bool IsListOf(Type type, out Type? elementType) + { + elementType = null; + + if (!type.IsGenericType || type.GetGenericTypeDefinition() != typeof(List<>)) + { + return false; + } + + elementType = type.GetGenericArguments()[0]; + return true; + + } +} diff --git a/util/Seeder/CLAUDE.md b/util/Seeder/CLAUDE.md index 7fa1ba7d5336..ab1a00db0771 100644 --- a/util/Seeder/CLAUDE.md +++ b/util/Seeder/CLAUDE.md @@ -28,7 +28,7 @@ Need to create test data? ├─ Flexible preset-based seeding? → Pipeline (RecipeBuilder + Steps) ├─ Complete test scenario with ID mangling? → Scene ├─ READ existing seeded data? → Query -└─ Data transformation SDK ↔ Server? → Model +└─ Data transformation plaintext ↔ encrypted? → Model ``` ## Pipeline Architecture @@ -95,19 +95,16 @@ The Seeder uses the Rust SDK via FFI because it must behave like a real Bitwarde ## Data Flow ``` -CipherViewDto → Rust SDK encrypt_cipher → EncryptedCipherDto → EncryptedCipherDtoExtensions → Server Cipher Entity +CipherViewDto → JSON + [EncryptProperty] field paths → encrypt_fields (Rust FFI, bitwarden_crypto) → EncryptedCipherDto → EncryptedCipherDtoExtensions → Server Cipher Entity ``` Shared logic: `CipherEncryption.cs`, `EncryptedCipherDtoExtensions.cs` -## Rust SDK Version Alignment +## Rust Crypto Dependency -| Component | Version Source | -| ----------- | ----------------------------------------- | -| Server Shim | `util/RustSdk/rust/Cargo.toml` git rev | -| Clients | `@bitwarden/sdk-internal` in clients repo | +The Rust shim (`util/RustSdk/rust/`) depends only on `bitwarden_crypto`. It does **not** depend on `bitwarden_vault` — the seeder drives field selection via `[EncryptProperty]` attributes, not SDK cipher types. -Before modifying SDK integration, run `RustSdkCipherTests` to validate roundtrip encryption. +Before modifying encryption integration, run `RustSdkCipherTests` to validate roundtrip encryption. ## Deterministic Data Generation diff --git a/util/Seeder/Factories/CipherEncryption.cs b/util/Seeder/Factories/CipherEncryption.cs index 17cf6d2c0f03..cd19b02d4ea2 100644 --- a/util/Seeder/Factories/CipherEncryption.cs +++ b/util/Seeder/Factories/CipherEncryption.cs @@ -4,29 +4,33 @@ using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; using Bit.RustSDK; +using Bit.Seeder.Attributes; using Bit.Seeder.Models; namespace Bit.Seeder.Factories; internal static class CipherEncryption { - private static readonly JsonSerializerOptions SdkJsonOptions = new() + private static readonly JsonSerializerOptions _sdkJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - private static readonly JsonSerializerOptions ServerJsonOptions = new() + private static readonly JsonSerializerOptions _serverJsonOptions = new() { PropertyNamingPolicy = null, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + private static readonly string _fieldPathsJson = + JsonSerializer.Serialize(EncryptPropertyAttribute.GetFieldPaths()); + internal static EncryptedCipherDto Encrypt(CipherViewDto cipherView, string keyBase64) { - var viewJson = JsonSerializer.Serialize(cipherView, SdkJsonOptions); - var encryptedJson = RustSdkService.EncryptCipher(viewJson, keyBase64); - return JsonSerializer.Deserialize(encryptedJson, SdkJsonOptions) + var viewJson = JsonSerializer.Serialize(cipherView, _sdkJsonOptions); + var encryptedJson = RustSdkService.EncryptFields(viewJson, _fieldPathsJson, keyBase64); + return JsonSerializer.Deserialize(encryptedJson, _sdkJsonOptions) ?? throw new InvalidOperationException("Failed to parse encrypted cipher"); } @@ -37,7 +41,7 @@ internal static Cipher CreateEntity( Guid? organizationId, Guid? userId) { - var dataJson = JsonSerializer.Serialize(data, ServerJsonOptions); + var dataJson = JsonSerializer.Serialize(data, _serverJsonOptions); return new Cipher { diff --git a/util/Seeder/Models/CipherViewDto.cs b/util/Seeder/Models/CipherViewDto.cs index 0b94d0b0f489..eccf0efea1ee 100644 --- a/util/Seeder/Models/CipherViewDto.cs +++ b/util/Seeder/Models/CipherViewDto.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Bit.Seeder.Attributes; namespace Bit.Seeder.Models; @@ -13,15 +14,14 @@ public class CipherViewDto [JsonPropertyName("folderId")] public Guid? FolderId { get; set; } - [JsonPropertyName("collectionIds")] - public List CollectionIds { get; set; } = []; - [JsonPropertyName("key")] public string? Key { get; set; } + [EncryptProperty] [JsonPropertyName("name")] public required string Name { get; set; } + [EncryptProperty] [JsonPropertyName("notes")] public string? Notes { get; set; } @@ -49,30 +49,9 @@ public class CipherViewDto [JsonPropertyName("reprompt")] public int Reprompt { get; set; } - [JsonPropertyName("organizationUseTotp")] - public bool OrganizationUseTotp { get; set; } - - [JsonPropertyName("edit")] - public bool Edit { get; set; } = true; - - [JsonPropertyName("permissions")] - public object? Permissions { get; set; } - - [JsonPropertyName("viewPassword")] - public bool ViewPassword { get; set; } = true; - - [JsonPropertyName("localData")] - public object? LocalData { get; set; } - - [JsonPropertyName("attachments")] - public object? Attachments { get; set; } - [JsonPropertyName("fields")] public List? Fields { get; set; } - [JsonPropertyName("passwordHistory")] - public object? PasswordHistory { get; set; } - [JsonPropertyName("creationDate")] public DateTime CreationDate { get; set; } = DateTime.UtcNow; @@ -81,16 +60,15 @@ public class CipherViewDto [JsonPropertyName("revisionDate")] public DateTime RevisionDate { get; set; } = DateTime.UtcNow; - - [JsonPropertyName("archivedDate")] - public DateTime? ArchivedDate { get; set; } } public class LoginViewDto { + [EncryptProperty] [JsonPropertyName("username")] public string? Username { get; set; } + [EncryptProperty] [JsonPropertyName("password")] public string? Password { get; set; } @@ -100,33 +78,32 @@ public class LoginViewDto [JsonPropertyName("uris")] public List? Uris { get; set; } + [EncryptProperty] [JsonPropertyName("totp")] public string? Totp { get; set; } - - [JsonPropertyName("autofillOnPageLoad")] - public bool? AutofillOnPageLoad { get; set; } - - [JsonPropertyName("fido2Credentials")] - public object? Fido2Credentials { get; set; } } public class LoginUriViewDto { + [EncryptProperty] [JsonPropertyName("uri")] public string? Uri { get; set; } [JsonPropertyName("match")] public int? Match { get; set; } + [EncryptProperty] [JsonPropertyName("uriChecksum")] public string? UriChecksum { get; set; } } public class FieldViewDto { + [EncryptProperty] [JsonPropertyName("name")] public string? Name { get; set; } + [EncryptProperty] [JsonPropertyName("value")] public string? Value { get; set; } @@ -153,91 +130,115 @@ public static class RepromptTypes } /// -/// Card cipher data for SDK encryption. Uses record for composition via `with` expressions. +/// Card cipher data. Uses record for composition via `with` expressions. /// public record CardViewDto { + [EncryptProperty] [JsonPropertyName("cardholderName")] public string? CardholderName { get; init; } + [EncryptProperty] [JsonPropertyName("brand")] public string? Brand { get; init; } + [EncryptProperty] [JsonPropertyName("number")] public string? Number { get; init; } + [EncryptProperty] [JsonPropertyName("expMonth")] public string? ExpMonth { get; init; } + [EncryptProperty] [JsonPropertyName("expYear")] public string? ExpYear { get; init; } + [EncryptProperty] [JsonPropertyName("code")] public string? Code { get; init; } } /// -/// Identity cipher data for SDK encryption. Uses record for composition via `with` expressions. +/// Identity cipher data. Uses record for composition via `with` expressions. /// public record IdentityViewDto { + [EncryptProperty] [JsonPropertyName("title")] public string? Title { get; init; } + [EncryptProperty] [JsonPropertyName("firstName")] public string? FirstName { get; init; } + [EncryptProperty] [JsonPropertyName("middleName")] public string? MiddleName { get; init; } + [EncryptProperty] [JsonPropertyName("lastName")] public string? LastName { get; init; } + [EncryptProperty] [JsonPropertyName("address1")] public string? Address1 { get; init; } + [EncryptProperty] [JsonPropertyName("address2")] public string? Address2 { get; init; } + [EncryptProperty] [JsonPropertyName("address3")] public string? Address3 { get; init; } + [EncryptProperty] [JsonPropertyName("city")] public string? City { get; init; } + [EncryptProperty] [JsonPropertyName("state")] public string? State { get; init; } + [EncryptProperty] [JsonPropertyName("postalCode")] public string? PostalCode { get; init; } + [EncryptProperty] [JsonPropertyName("country")] public string? Country { get; init; } + [EncryptProperty] [JsonPropertyName("company")] public string? Company { get; init; } + [EncryptProperty] [JsonPropertyName("email")] public string? Email { get; init; } + [EncryptProperty] [JsonPropertyName("phone")] public string? Phone { get; init; } + [EncryptProperty] [JsonPropertyName("ssn")] public string? SSN { get; init; } + [EncryptProperty] [JsonPropertyName("username")] public string? Username { get; init; } + [EncryptProperty] [JsonPropertyName("passportNumber")] public string? PassportNumber { get; init; } + [EncryptProperty] [JsonPropertyName("licenseNumber")] public string? LicenseNumber { get; init; } } /// -/// SecureNote cipher data for SDK encryption. Minimal structure - content is in cipher.Notes. +/// SecureNote cipher data. Minimal structure - content is in cipher.Notes. /// public record SecureNoteViewDto { @@ -246,17 +247,19 @@ public record SecureNoteViewDto } /// -/// SSH Key cipher data for SDK encryption. Uses record for composition via `with` expressions. +/// SSH Key cipher data. Uses record for composition via `with` expressions. /// public record SshKeyViewDto { + [EncryptProperty] [JsonPropertyName("privateKey")] public string? PrivateKey { get; init; } + [EncryptProperty] [JsonPropertyName("publicKey")] public string? PublicKey { get; init; } - /// SDK expects "fingerprint" field name. + [EncryptProperty] [JsonPropertyName("fingerprint")] public string? Fingerprint { get; init; } } diff --git a/util/Seeder/README.md b/util/Seeder/README.md index f2344f31fc4f..20bf83813ff2 100644 --- a/util/Seeder/README.md +++ b/util/Seeder/README.md @@ -85,16 +85,16 @@ The Seeder is organized around six core patterns, each with a specific responsib #### Models -**Purpose:** DTOs that bridge the gap between SDK encryption format and server storage format. +**Purpose:** DTOs that transform plaintext cipher data into encrypted form for database storage. -**When to use:** Need data transformation during the encryption pipeline (SDK → Server format). +**When to use:** Need to convert `CipherViewDto` to `EncryptedCipherDto` during the encryption pipeline. **Key characteristics:** - Pure data structures (DTOs) - No business logic -- Handle serialization/deserialization -- Bridge SDK ↔ Server format differences +- Handle serialization/deserialization (camelCase ↔ PascalCase) +- Mark encryptable fields with `[EncryptProperty]` attribute #### Scenes @@ -150,7 +150,7 @@ Context-aware string mangling for test isolation. Adds unique prefixes to emails The seeder uses FFI calls to the Rust SDK for cryptographically correct encryption: ``` -CipherView → Rust SDK encrypt → EncryptedCipher → Server Format +CipherViewDto → encrypt_fields (field-level encryption via bitwarden_crypto) → EncryptedCipherDto → Server Format ``` This ensures seeded data can be decrypted and displayed in the actual Bitwarden clients. diff --git a/util/SeederUtility/README.md b/util/SeederUtility/README.md index 63fc6a72d89d..754fbdda4751 100644 --- a/util/SeederUtility/README.md +++ b/util/SeederUtility/README.md @@ -60,16 +60,16 @@ dotnet run -- organization -n TeamsOrg -d teams.example -u 20 -c 200 -g 5 --plan dotnet run -- seed --list # Load the Dunder Mifflin preset (58 users, 14 groups, 15 collections, ciphers) -dotnet run -- seed --preset dunder-mifflin-enterprise-full +dotnet run -- seed --preset qa.dunder-mifflin-enterprise-full # Load with ID mangling for test isolation -dotnet run -- seed --preset dunder-mifflin-enterprise-full --mangle +dotnet run -- seed --preset qa.dunder-mifflin-enterprise-full --mangle -dotnet run -- seed --preset stark-free-basic --mangle +dotnet run -- seed --preset qa.stark-free-basic --mangle -# Large enterprise preset for performance testing -dotnet run -- seed --preset large-enterprise +# Scale preset for performance testing +dotnet run -- seed --preset scale.xs-central-perk --mangle -dotnet run -- seed --preset dunder-mifflin-enterprise-full --password "MyTestPassword1" --mangle +dotnet run -- seed --preset qa.dunder-mifflin-enterprise-full --password "MyTestPassword1" --mangle ``` From 2e3c71f80c108288dee121dad7f2a5a0277bcd6e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:55:08 -0400 Subject: [PATCH 75/85] [deps] BRE: Update mariadb Docker tag to v12 (#7119) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- dev/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index b3d2b0bb5bf5..cc7033fc8eae 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -72,7 +72,7 @@ services: - ef mariadb: - image: mariadb:10 + image: mariadb:12 ports: - 4306:3306 environment: From 6d2acaf6bbf38fbd17cd7339004525b7fc9ec2ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:48:37 +0000 Subject: [PATCH 76/85] [PM-19143] Refactor public API MembersController POST to use CommandResult pattern (#7182) * Add CommandResultRefactor constant to FeatureFlagKeys in Constants.cs * Add method to convert MemberCreateRequestModel to InviteOrganizationUsersRequest - Introduced ToInviteRequest method for transforming MemberCreateRequestModel into InviteOrganizationUsersRequest. - Enhanced model with additional using directives for improved functionality. * Update GetInviterEmailAsync method to include a check for Guid.Empty to prevent unnecessary DB lookups * Feature flag MembersController POST to use InviteOrganizationUsersCommand Add a new code path behind the CommandResultRefactor feature flag that replaces the legacy InviteUserAsync call with the InviteOrganizationUsersCommand. Integration tests verify both paths produce identical results. * Refactor feature flag for member invites from CommandResultRefactor to PublicMembersInviteRefactor in MembersController and update related tests. --- .../Public/Controllers/MembersController.cs | 53 +++++++++++- .../Request/MemberCreateRequestModel.cs | 31 +++++++ .../SendOrganizationInvitesCommand.cs | 2 +- .../UpdateOrganizationSubscriptionCommand.cs | 4 +- src/Core/Constants.cs | 1 + .../Controllers/MembersControllerTests.cs | 80 +++++++++++++++++++ 6 files changed, 168 insertions(+), 3 deletions(-) diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index e312f009c9b9..3e6591beb52b 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -2,20 +2,28 @@ 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.Models.Business; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.Commands; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper; namespace Bit.Api.AdminConsole.Public.Controllers; @@ -36,6 +44,10 @@ public class MembersController : Controller private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand; private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; + private readonly IFeatureService _featureService; + private readonly IInviteOrganizationUsersCommand _inviteOrganizationUsersCommand; + private readonly IPricingClient _pricingClient; + private readonly TimeProvider _timeProvider; public MembersController( IOrganizationUserRepository organizationUserRepository, @@ -50,7 +62,11 @@ public MembersController( IRemoveOrganizationUserCommand removeOrganizationUserCommand, IResendOrganizationInviteCommand resendOrganizationInviteCommand, IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2, - IRestoreOrganizationUserCommand restoreOrganizationUserCommand) + IRestoreOrganizationUserCommand restoreOrganizationUserCommand, + IFeatureService featureService, + IInviteOrganizationUsersCommand inviteOrganizationUsersCommand, + IPricingClient pricingClient, + TimeProvider timeProvider) { _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; @@ -65,6 +81,10 @@ public MembersController( _resendOrganizationInviteCommand = resendOrganizationInviteCommand; _revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2; _restoreOrganizationUserCommand = restoreOrganizationUserCommand; + _featureService = featureService; + _inviteOrganizationUsersCommand = inviteOrganizationUsersCommand; + _pricingClient = pricingClient; + _timeProvider = timeProvider; } /// @@ -156,6 +176,10 @@ public async Task Post([FromBody] MemberCreateRequestModel model) } var invite = model.ToOrganizationUserInvite(); + if (_featureService.IsEnabled(FeatureFlagKeys.PublicMembersInviteRefactor)) + { + return await PostInviteUserAsync_vNext(model, organization!, hasStandaloneSecretsManager); + } invite.AccessSecretsManager = hasStandaloneSecretsManager; @@ -165,6 +189,33 @@ public async Task Post([FromBody] MemberCreateRequestModel model) return new JsonResult(response); } + private async Task PostInviteUserAsync_vNext( + MemberCreateRequestModel model, + Core.AdminConsole.Entities.Organization organization, + bool hasStandaloneSecretsManager) + { + var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + var inviteOrganization = new InviteOrganization(organization, plan); + var request = model.ToInviteRequest(inviteOrganization, hasStandaloneSecretsManager, Guid.Empty, _timeProvider.GetUtcNow()); + + var result = await _inviteOrganizationUsersCommand.InviteImportedOrganizationUsersAsync(request); + + switch (result) + { + case Success success: + var user = success.Value.InvitedUsers.First(); + var collections = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList(); + var response = new MemberResponseModel(user, collections); + return new JsonResult(response); + case Failure { Error.Message: NoUsersToInviteError.Code }: + throw new BadRequestException("This user has already been invited."); + case Failure failure: + throw MapToBitException(failure.Error); + default: + throw new InvalidOperationException(); + } + } + /// /// Update a member. /// diff --git a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs index b3182601b5fd..c3ce7ba1db6b 100644 --- a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs @@ -2,9 +2,12 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Models.Business; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; +using Bit.Core.Models.Data; using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Public.Models.Request; @@ -43,4 +46,32 @@ public OrganizationUserInvite ToOrganizationUserInvite() return invite; } + + public InviteOrganizationUsersRequest ToInviteRequest( + InviteOrganization inviteOrganization, + bool accessSecretsManager, + Guid performedBy, + DateTimeOffset performedAt) + { + // Permissions property is optional for backwards compatibility with existing usage + var permissions = (Type is OrganizationUserType.Custom && Permissions is not null) + ? Permissions.ToData() + : new Permissions(); + + return new InviteOrganizationUsersRequest( + invites: + [ + new OrganizationUserInviteCommandModel( + email: Email, + assignedCollections: Collections?.Select(c => c.ToCollectionAccessSelection()) ?? [], + groups: Groups ?? [], + type: Type!.Value, + permissions: permissions, + externalId: ExternalId, + accessSecretsManager: accessSecretsManager) + ], + inviteOrganization: inviteOrganization, + performedBy: performedBy, + performedAt: performedAt); + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs index f97303ac87c0..496c6be275ac 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -85,7 +85,7 @@ private async Task BuildOrganizationInvitesInfoAsync(IE private async Task GetInviterEmailAsync(Guid? invitingUserId) { - if (!invitingUserId.HasValue) + if (!invitingUserId.HasValue || invitingUserId.Value == Guid.Empty) { return null; } diff --git a/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs b/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs index befed75db4a9..ff439c2e486f 100644 --- a/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs +++ b/src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs @@ -39,7 +39,9 @@ public class UpdateOrganizationSubscriptionCommand( { private static readonly List _validSubscriptionStatusesForUpdate = [ - SubscriptionStatus.Trialing, SubscriptionStatus.Active, SubscriptionStatus.PastDue + SubscriptionStatus.Trialing, + SubscriptionStatus.Active, + SubscriptionStatus.PastDue ]; private readonly ILogger _logger = logger; diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 354ac158cdb9..f3ea02509c8a 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -143,6 +143,7 @@ public static class FeatureFlagKeys public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance"; public const string BulkReinviteUI = "pm-28416-bulk-reinvite-ux-improvements"; public const string RefactorOrgAcceptInit = "pm-33082-refactor-org-accept-init"; + public const string PublicMembersInviteRefactor = "pm-33398-refactor-members-invite-org-users-command"; /* Architecture */ public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs index e4bdbdb1749c..b4ce80ded195 100644 --- a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs @@ -5,12 +5,15 @@ using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Public.Response; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Test.Common.Helpers; +using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.AdminConsole.Public.Controllers; @@ -28,6 +31,7 @@ public class MembersControllerTests : IClassFixture, IAsy public MembersControllerTests(ApiApplicationFactory factory) { _factory = factory; + _factory.SubstituteService(_ => { }); _client = factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } @@ -398,4 +402,80 @@ public async Task Restore_DifferentOrganization_ReturnsNotFound() Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + + [Fact] + public async Task Post_CustomMember_WithPublicMembersInviteRefactor_Success() + { + var featureService = _factory.GetService(); + featureService + .IsEnabled(FeatureFlagKeys.PublicMembersInviteRefactor) + .Returns(true); + + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + var request = new MemberCreateRequestModel + { + Email = email, + Type = OrganizationUserType.Custom, + ExternalId = "myCustomUser", + Collections = [], + Groups = [] + }; + + var response = await _client.PostAsync("/public/members", JsonContent.Create(request)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + + Assert.Equal(email, result.Email); + Assert.Equal(OrganizationUserType.Custom, result.Type); + Assert.Equal("myCustomUser", result.ExternalId); + Assert.Empty(result.Collections); + + var organizationUserRepository = _factory.GetService(); + var orgUser = await organizationUserRepository.GetByIdAsync(result.Id); + + Assert.NotNull(orgUser); + Assert.Equal(email, orgUser.Email); + Assert.Equal(OrganizationUserType.Custom, orgUser.Type); + Assert.Equal("myCustomUser", orgUser.ExternalId); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.Equal(_organization.Id, orgUser.OrganizationId); + } + + [Fact] + public async Task Post_UserMember_WithPublicMembersInviteRefactor_Success() + { + var featureService = _factory.GetService(); + featureService + .IsEnabled(FeatureFlagKeys.PublicMembersInviteRefactor) + .Returns(true); + + var email = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + var request = new MemberCreateRequestModel + { + Email = email, + Type = OrganizationUserType.User, + Collections = [], + Groups = [] + }; + + var response = await _client.PostAsync("/public/members", JsonContent.Create(request)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + + Assert.Equal(email, result.Email); + Assert.Equal(OrganizationUserType.User, result.Type); + + var organizationUserRepository = _factory.GetService(); + var orgUser = await organizationUserRepository.GetByIdAsync(result.Id); + + Assert.NotNull(orgUser); + Assert.Equal(email, orgUser.Email); + Assert.Equal(OrganizationUserType.User, orgUser.Type); + Assert.Equal(OrganizationUserStatusType.Invited, orgUser.Status); + Assert.Equal(_organization.Id, orgUser.OrganizationId); + } } From cc12ecaad1f7b2a61fe7fea64ef1e44d9a0f9a32 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:22:52 -0500 Subject: [PATCH 77/85] [PM-31657] Address Overwriting Attachments (#7053) * check permissions when uploading attachment for self hosted users to remove possibility of overwriting an existing attachment. * expose `ValidateCipherEditForAttachmentAsync` * add additional logic to support admin users * add unit tests for new edit checks --- .../Vault/Controllers/CiphersController.cs | 19 ++- src/Core/Vault/Services/ICipherService.cs | 3 +- .../Services/Implementations/CipherService.cs | 6 +- .../Controllers/CiphersControllerTests.cs | 145 ++++++++++++++++++ .../Vault/Services/CipherServiceTests.cs | 101 +++++++++++- 5 files changed, 269 insertions(+), 5 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index eb658eacf1d1..ccc5cf72e713 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -1388,6 +1388,14 @@ public async Task RenewFileUploadUrl(Guid id, { var userId = _userService.GetProperUserId(User).Value; var cipher = await GetByIdAsync(id, userId); + + var orgAdmin = false; + if (cipher.OrganizationId.HasValue && + await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) + { + orgAdmin = true; + } + var attachments = cipher?.GetAttachments(); if (attachments == null || !attachments.TryGetValue(attachmentId, out var attachment) || attachment.Validated) @@ -1395,6 +1403,8 @@ public async Task RenewFileUploadUrl(Guid id, throw new NotFoundException(); } + await _cipherService.ValidateCipherEditForAttachmentAsync(cipher, userId, orgAdmin, attachment.Size); + return new AttachmentUploadDataResponseModel { Url = await _attachmentStorageService.GetAttachmentUploadUrlAsync(cipher, attachment), @@ -1415,6 +1425,13 @@ public async Task PostFileForExistingAttachment(Guid id, string attachmentId) var userId = _userService.GetProperUserId(User).Value; var cipher = await GetByIdAsync(id, userId); + + var orgAdmin = false; + if (cipher.OrganizationId.HasValue && + await CanEditCipherAsAdminAsync(cipher.OrganizationId.Value, new[] { cipher.Id })) + { + orgAdmin = true; + } var attachments = cipher?.GetAttachments(); if (attachments == null || !attachments.TryGetValue(attachmentId, out var attachmentData)) { @@ -1423,7 +1440,7 @@ public async Task PostFileForExistingAttachment(Guid id, string attachmentId) await Request.GetFileAsync(async (stream) => { - await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData); + await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData, userId, orgAdmin); }); } diff --git a/src/Core/Vault/Services/ICipherService.cs b/src/Core/Vault/Services/ICipherService.cs index da01b55ab174..ae5d622f1f33 100644 --- a/src/Core/Vault/Services/ICipherService.cs +++ b/src/Core/Vault/Services/ICipherService.cs @@ -33,8 +33,9 @@ Task> ShareManyAsync(IEnumerable<(CipherDetails ciphe Task SoftDeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false); Task RestoreAsync(CipherDetails cipherDetails, Guid restoringUserId, bool orgAdmin = false); Task> RestoreManyAsync(IEnumerable cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false); - Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId); + Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId, Guid savingUserId, bool orgAdmin = false); Task GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId); Task ValidateCipherAttachmentFile(Cipher cipher, CipherAttachment.MetaData attachmentData); Task ValidateBulkCollectionAssignmentAsync(IEnumerable collectionIds, IEnumerable cipherIds, Guid userId); + Task ValidateCipherEditForAttachmentAsync(Cipher cipher, Guid savingUserId, bool orgAdmin, long requestLength); } diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 5f235879c676..92ccd47c70c7 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -187,8 +187,10 @@ public async Task SaveDetailsAsync(CipherDetails cipher, Guid savingUserId, Date } } - public async Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachment) + public async Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachment, Guid savingUserId, bool orgAdmin = false) { + await ValidateCipherEditForAttachmentAsync(cipher, savingUserId, orgAdmin, attachment.Size); + if (attachment == null) { throw new BadRequestException("Cipher attachment does not exist"); @@ -910,7 +912,7 @@ private async Task DeleteAttachmentAsync(Cipher ci return new DeleteAttachmentResponseData(cipher); } - private async Task ValidateCipherEditForAttachmentAsync(Cipher cipher, Guid savingUserId, bool orgAdmin, + public async Task ValidateCipherEditForAttachmentAsync(Cipher cipher, Guid savingUserId, bool orgAdmin, long requestLength) { if (!orgAdmin && !(await UserCanEditAsync(cipher, savingUserId))) diff --git a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs index 85e6e7ad9367..97704fac3d47 100644 --- a/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs +++ b/test/Api.Test/Vault/Controllers/CiphersControllerTests.cs @@ -18,6 +18,7 @@ using Bit.Core.Vault.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -2173,6 +2174,150 @@ public async Task PutShare_UpdateExistingFolderAndFavorite_UpdatesUserSpecificFi Assert.True(result.Favorite); } + [Theory, BitAutoData] + public async Task RenewFileUploadUrl_WithReadOnlyUser_ThrowsBadRequest( + Guid cipherId, string attachmentId, Guid userId, Guid organizationId, + SutProvider sutProvider) + { + var attachmentData = new CipherAttachment.MetaData + { + Size = 100, + FileName = "test.txt", + Validated = false + }; + + var cipherDetails = new CipherDetails + { + Id = cipherId, + OrganizationId = organizationId, + Type = CipherType.Login, + Data = "{}", + Edit = false + }; + cipherDetails.SetAttachments(new Dictionary + { + { attachmentId, attachmentData } + }); + + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().GetByIdAsync(cipherId, userId).Returns(cipherDetails); + sutProvider.GetDependency().GetOrganization(organizationId).ReturnsNull(); + sutProvider.GetDependency() + .ValidateCipherEditForAttachmentAsync(cipherDetails, userId, false, attachmentData.Size) + .ThrowsAsync(new BadRequestException("You do not have permissions to edit this.")); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.RenewFileUploadUrl(cipherId, attachmentId)); + Assert.Equal("You do not have permissions to edit this.", exception.Message); + } + + [Theory] + [BitAutoData(OrganizationUserType.Owner)] + [BitAutoData(OrganizationUserType.Admin)] + public async Task RenewFileUploadUrl_WithOrgAdmin_Success( + OrganizationUserType userType, Guid cipherId, string attachmentId, Guid userId, + CurrentContextOrganization organization, SutProvider sutProvider) + { + var attachmentData = new CipherAttachment.MetaData + { + Size = 100, + FileName = "test.txt", + Validated = false + }; + + var cipherDetails = new CipherDetails + { + Id = cipherId, + OrganizationId = organization.Id, + Type = CipherType.Login, + Data = "{}" + }; + cipherDetails.SetAttachments(new Dictionary + { + { attachmentId, attachmentData } + }); + + organization.Type = userType; + + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().GetByIdAsync(cipherId, userId).Returns(cipherDetails); + sutProvider.GetDependency().GetOrganization(organization.Id).Returns(organization); + sutProvider.GetDependency().GetManyByOrganizationIdAsync(organization.Id) + .Returns(new List { cipherDetails }); + sutProvider.GetDependency().GetOrganizationAbilityAsync(organization.Id) + .Returns(new OrganizationAbility { Id = organization.Id, AllowAdminAccessToAllCollectionItems = true }); + + var expectedUrl = "https://example.com/upload"; + sutProvider.GetDependency() + .GetAttachmentUploadUrlAsync(cipherDetails, Arg.Any()) + .Returns(expectedUrl); + + var result = await sutProvider.Sut.RenewFileUploadUrl(cipherId, attachmentId); + + Assert.Equal(expectedUrl, result.Url); + await sutProvider.GetDependency().Received(1) + .ValidateCipherEditForAttachmentAsync(cipherDetails, userId, true, attachmentData.Size); + } + + [Theory, BitAutoData] + public async Task RenewFileUploadUrl_WithMissingAttachment_ThrowsNotFoundException( + Guid cipherId, string attachmentId, Guid userId, SutProvider sutProvider) + { + var cipherDetails = new CipherDetails + { + Id = cipherId, + Type = CipherType.Login, + Data = "{}" + }; + + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().GetByIdAsync(cipherId, userId).Returns(cipherDetails); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RenewFileUploadUrl(cipherId, attachmentId)); + } + + [Theory, BitAutoData] + public async Task RenewFileUploadUrl_WithValidatedAttachment_ThrowsNotFoundException( + Guid cipherId, string attachmentId, Guid userId, SutProvider sutProvider) + { + var attachmentData = new CipherAttachment.MetaData + { + Size = 100, + FileName = "test.txt", + Validated = true + }; + + var cipherDetails = new CipherDetails + { + Id = cipherId, + Type = CipherType.Login, + Data = "{}" + }; + cipherDetails.SetAttachments(new Dictionary + { + { attachmentId, attachmentData } + }); + + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + sutProvider.GetDependency().GetByIdAsync(cipherId, userId).Returns(cipherDetails); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RenewFileUploadUrl(cipherId, attachmentId)); + } + + [Theory, BitAutoData] + public async Task PostFileForExistingAttachment_WithInvalidContentType_ThrowsBadRequest( + Guid cipherId, string attachmentId, Guid userId, SutProvider sutProvider) + { + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.ContentType = "application/json"; + sutProvider.Sut.ControllerContext = new ControllerContext { HttpContext = httpContext }; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PostFileForExistingAttachment(cipherId, attachmentId)); + Assert.Equal("Invalid content.", exception.Message); + } [Theory, BitAutoData] public async Task GetAttachmentData_CipherNotFound_ThrowsNotFoundException( Guid cipherId, string attachmentId, Guid userId, diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 56430d7d7320..5a2762ba8782 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -2576,9 +2576,108 @@ private async Task AssertNoActionsAsync(SutProvider sutProvider) await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushSyncCiphersAsync(default); } + [Theory, BitAutoData] + public async Task UploadFileForExistingAttachmentAsync_ReadOnlyUser_ThrowsBadRequest( + SutProvider sutProvider, Cipher cipher, Guid savingUserId) + { + cipher.OrganizationId = Guid.NewGuid(); + cipher.UserId = null; + + var attachment = new CipherAttachment.MetaData + { + Size = 100, + FileName = "test.txt" + }; + + sutProvider.GetDependency() + .GetCanEditByIdAsync(savingUserId, cipher.Id) + .Returns(false); + + using var stream = new MemoryStream(new byte[100]); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UploadFileForExistingAttachmentAsync(stream, cipher, attachment, savingUserId, false)); + Assert.Equal("You do not have permissions to edit this.", exception.Message); + } + + [Theory, BitAutoData] + public async Task ValidateCipherEditForAttachmentAsync_ReadOnlyUser_ThrowsBadRequest( + SutProvider sutProvider, Cipher cipher, Guid savingUserId) + { + cipher.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetCanEditByIdAsync(savingUserId, cipher.Id) + .Returns(false); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ValidateCipherEditForAttachmentAsync(cipher, savingUserId, false, 100)); + Assert.Equal("You do not have permissions to edit this.", exception.Message); + } + + [Theory, BitAutoData] + public async Task ValidateCipherEditForAttachmentAsync_OrgAdmin_BypassesEditCheck( + SutProvider sutProvider, Cipher cipher, Guid savingUserId) + { + cipher.OrganizationId = Guid.NewGuid(); + cipher.UserId = null; + + sutProvider.GetDependency() + .GetCanEditByIdAsync(savingUserId, cipher.Id) + .Returns(false); + + var organization = new Organization + { + Id = cipher.OrganizationId.Value, + MaxStorageGb = 100 + }; + sutProvider.GetDependency() + .GetByIdAsync(cipher.OrganizationId.Value) + .Returns(organization); + + await sutProvider.Sut.ValidateCipherEditForAttachmentAsync(cipher, savingUserId, true, 100); + + await sutProvider.GetDependency().DidNotReceive() + .GetCanEditByIdAsync(savingUserId, cipher.Id); + } + + [Theory, BitAutoData] + public async Task ValidateCipherEditForAttachmentAsync_ZeroRequestLength_ThrowsBadRequest( + SutProvider sutProvider, Cipher cipher, Guid savingUserId) + { + cipher.UserId = savingUserId; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ValidateCipherEditForAttachmentAsync(cipher, savingUserId, true, 0)); + Assert.Equal("No data to attach.", exception.Message); + } + + [Theory, BitAutoData] + public async Task ValidateCipherEditForAttachmentAsync_UserWithEditPermission_Succeeds( + SutProvider sutProvider, Cipher cipher, Guid savingUserId, User user) + { + cipher.UserId = savingUserId; + cipher.OrganizationId = null; + + user.Id = savingUserId; + user.Premium = true; + user.MaxStorageGb = 1; + user.Storage = 0; + + sutProvider.GetDependency() + .GetByIdAsync(savingUserId) + .Returns(user); + + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(true); + + await sutProvider.Sut.ValidateCipherEditForAttachmentAsync(cipher, savingUserId, false, 100); + } + [Theory, BitAutoData] public async Task GetAttachmentDownloadDataAsync_NullCipher_ThrowsNotFoundException( - string attachmentId, SutProvider sutProvider) + string attachmentId, SutProvider sutProvider) { await Assert.ThrowsAsync( () => sutProvider.Sut.GetAttachmentDownloadDataAsync(null, attachmentId)); From f448cc1e5797e1111dfebdb52471c5b7a7411830 Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:21:33 -0600 Subject: [PATCH 78/85] SHOT-71: Migrate self-host ownership over to SHOT (#7213) * Migrate self-host ownership over to SHOT * Set devcontainers to multi owner * Update CODEOWNERS for docker-compose.yml * We already have a multiple owner section --- .github/CODEOWNERS | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fc6ef584b5aa..301b1f06093d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,11 +5,11 @@ # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners ## Docker-related files -**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre -**/*.Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre -**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre -**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre -**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre +**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-shot +**/*.Dockerfile @bitwarden/team-appsec @bitwarden/dept-shot +**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-shot +**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-shot +**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-shot # Scanning tools .checkmarx/ @bitwarden/team-appsec @@ -35,7 +35,8 @@ util/SqlServerEFScaffold/** @bitwarden/dept-dbops util/SqliteMigrations/** @bitwarden/dept-dbops # Shared util projects -util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev +util/MsSqlMigratorUtility/** @bitwarden/team-platform-dev +util/Setup/** @bitwarden/dept-shot @bitwarden/team-platform-dev # UIF src/Core/MailTemplates/Mjml @bitwarden/team-ui-foundation # Teams are expected to own sub-directories of this project @@ -111,6 +112,8 @@ util/RustSdk @bitwarden/team-sdk-sme # Multiple owners - DO NOT REMOVE (BRE) **/packages.lock.json Directory.Build.props +.devcontainer/** +dev/docker-compose.yml # Claude related files .claude/ @bitwarden/team-ai-sme From ced94d5c80926e5169f3f75a69694596b2d3724d Mon Sep 17 00:00:00 2001 From: Amy Galles <9685081+AmyLGalles@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:08:15 -0700 Subject: [PATCH 79/85] create new dockerfile for SeederApi (#7072) * create new dockerfile for SeederApi * troubleshoot cargo issues * troubleshoot cargo issues * Ensure Rustup run on build env for appropriate target * Musl targets do not support cdylibs * Ensure default triple set to target * Set target triple rather than update default host * Change build platforms per project * Switch to debian since we can't use musl * Debian build for seeder should work with arm targets * Move app stage to distroless * remove SeederApi from server publish section * suppress unrelated warnings" * ruling out builds as error source * override platforms for SeederApi * troubleshoot matrix * add extra step for evaluating platforms * fix syntax error * exclude unrelated error * exclude unrelated error * exclude unrelated error * exclude unrelated error * exclude unrelated error * temporarily reduce number of builds * exclude unrelated error * remove temporary block on other builds * remove unused builds from dockerfile * add nginx location for seeder, wrap it behind an if check defaulting to false. This was discuss with Matt G, as this will enable QA usage of it without repetitive intervention with config files and reloading the nginx service etc. Handlebars will continously overwrite the nginx conf file on update * opted to remove conditional location to seederApi, instead include additional conf files in the same directory allowing for extensibility and not directly placing the non-prod seeder location in the config builder --------- Co-authored-by: Matt Gibson Co-authored-by: AJ Mabry <81774843+aj-bw@users.noreply.github.com> --- .github/workflows/build.yml | 19 ++++- util/SeederApi/Dockerfile | 115 +++++++++++++++++++++++++++ util/Setup/Templates/NginxConfig.hbs | 2 + 3 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 util/SeederApi/Dockerfile diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a431b9242f9d..e6446886b7a9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -87,6 +87,10 @@ jobs: - project_name: Scim base_path: ./bitwarden_license/src dotnet: true + - project_name: SeederApi + base_path: ./util + platforms: linux/amd64,linux/arm64 + dotnet: true - project_name: Setup base_path: ./util dotnet: true @@ -214,6 +218,7 @@ jobs: echo "Matrix name: ${{ matrix.project_name }}" echo "PROJECT_NAME: $PROJECT_NAME" echo "project_name=$PROJECT_NAME" >> "$GITHUB_OUTPUT" + echo "platforms: ${{ matrix.platforms }}" >> "$GITHUB_STEP_SUMMARY" - name: Generate image tags(s) id: image-tags @@ -230,16 +235,22 @@ jobs: fi echo "tags=$TAGS" >> "$GITHUB_OUTPUT" + - name: Set platforms + id: platforms + run: | + PLATFORMS="${{ matrix.platforms }}" + if [ -z "$PLATFORMS" ]; then + PLATFORMS="linux/amd64,linux/arm/v7,linux/arm64" + fi + echo "platforms=$PLATFORMS" >> "$GITHUB_OUTPUT" + - name: Build Docker image id: build-artifacts uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile - platforms: | - linux/amd64, - linux/arm/v7, - linux/arm64 + platforms: ${{ steps.platforms.outputs.platforms }} push: true tags: ${{ steps.image-tags.outputs.tags }} diff --git a/util/SeederApi/Dockerfile b/util/SeederApi/Dockerfile new file mode 100644 index 000000000000..14f7a5bc1134 --- /dev/null +++ b/util/SeederApi/Dockerfile @@ -0,0 +1,115 @@ +############################################### +# Build stage # +############################################### +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS build + +# Docker buildx supplies these values +ARG TARGETPLATFORM +ARG BUILDPLATFORM + +# Install base build dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + curl \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Rust toolchain on build platform +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ + --default-toolchain stable \ + --profile minimal \ + --no-modify-path + +ENV PATH="/root/.cargo/bin:${PATH}" + +# Determine target architecture and install cross-compilation tools +RUN case "$TARGETPLATFORM" in \ + "linux/amd64") \ + RUST_TARGET=x86_64-unknown-linux-gnu && \ + RID=linux-x64 && \ + ARCH_PACKAGES="" \ + ;; \ + "linux/arm64") \ + RUST_TARGET=aarch64-unknown-linux-gnu && \ + RID=linux-arm64 && \ + ARCH_PACKAGES="gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross" \ + ;; \ + *) \ + echo "Unsupported platform: $TARGETPLATFORM" && exit 1 \ + ;; \ + esac \ + && if [ -n "$ARCH_PACKAGES" ]; then \ + apt-get update && apt-get install -y $ARCH_PACKAGES && rm -rf /var/lib/apt/lists/* ; \ + fi \ + && echo "RUST_TARGET=${RUST_TARGET}" >> /etc/environment \ + && echo "RID=${RID}" >> /etc/environment \ + && . /etc/environment \ + && rustup target add ${RUST_TARGET} \ + && echo "Rust target: ${RUST_TARGET}, .NET RID: ${RID}" + +# Configure Rust for cross-compilation with proper linkers +RUN . /etc/environment \ + && mkdir -p /root/.cargo \ + && case "$TARGETPLATFORM" in \ + "linux/amd64") \ + echo "[target.x86_64-unknown-linux-gnu]" >> /root/.cargo/config.toml \ + && echo "linker = \"gcc\"" >> /root/.cargo/config.toml \ + ;; \ + "linux/arm64") \ + echo "[target.aarch64-unknown-linux-gnu]" >> /root/.cargo/config.toml \ + && echo "linker = \"aarch64-linux-gnu-gcc\"" >> /root/.cargo/config.toml \ + ;; \ + esac + +# Copy project files +WORKDIR /source +COPY . ./ + +# Restore .NET dependencies +WORKDIR /source/util/SeederApi +RUN . /etc/environment && dotnet restore -r ${RID} + +# Build the project with Rust support +WORKDIR /source/util/SeederApi +RUN . /etc/environment \ + && export CARGO_TARGET_DIR=/tmp/cargo_target \ + && export NoWarn="CA1305;CS1591" \ + && rustc --version \ + && cargo --version \ + && echo "Building for Rust target: ${RUST_TARGET}, .NET RID: ${RID}" \ + && dotnet publish SeederApi.csproj \ + -c Release \ + --no-restore \ + --self-contained \ + /p:PublishSingleFile=true \ + -r ${RID} \ + -o /app/out + +############################################### +# App stage # +############################################### +FROM mcr.microsoft.com/dotnet/aspnet:8.0-azurelinux3.0-distroless-extra AS app + +ARG TARGETPLATFORM +LABEL com.bitwarden.product="bitwarden" + +ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:5000 +EXPOSE 5000 + +# Set up health check wrapper +# Get the executable and copy it to any path you want +COPY --from=ghcr.io/alexaka1/distroless-dotnet-healthchecks:1 / /healthcheck +# Setup your healthcheck endpoints via environment variable in Dockerfile, or at runtime via `docker run -e DISTROLESS_HEALTHCHECKS_URIS__0="http://localhost/healthz" -e DISTROLESS_HEALTHCHECKS_URIS__1="http://localhost/some/other/endpoint"` +ENV DISTROLESS_HEALTHCHECKS_URI="http://localhost:5000/alive" +# Setup the healthcheck using the EXEC array syntax +HEALTHCHECK CMD ["/healthcheck/Distroless.HealthChecks"] + +# Copy app from the build stage +WORKDIR /app +COPY --from=build /app/out /app + +ENTRYPOINT ["/app/SeederApi"] diff --git a/util/Setup/Templates/NginxConfig.hbs b/util/Setup/Templates/NginxConfig.hbs index 2319e82402ac..3c42a3058cc0 100644 --- a/util/Setup/Templates/NginxConfig.hbs +++ b/util/Setup/Templates/NginxConfig.hbs @@ -173,4 +173,6 @@ server { proxy_pass http://scim:5000/; } {{/if}} + + include /etc/bitwarden/nginx/extra-locations/*.conf; } From 5bc720b1e1eace0df9471392e8c3057265f71d89 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:48:52 -0700 Subject: [PATCH 80/85] introduce feature flag pm-31885-send-controls (#7134) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index f3ea02509c8a..408825b448e3 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -239,6 +239,7 @@ public static class FeatureFlagKeys public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe"; public const string SendUIRefresh = "pm-28175-send-ui-refresh"; public const string SendEmailOTP = "pm-19051-send-email-verification"; + public const string SendControls = "pm-31885-send-controls"; /* Vault Team */ public const string CipherKeyEncryption = "cipher-key-encryption"; From 869ee0fe951f0d11f2dd94c824d199573457050b Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Sat, 14 Mar 2026 17:34:21 -0400 Subject: [PATCH 81/85] chore(flags:): [PM-30245] Remove locked and inactive notifications feature flags from server --- src/Core/Constants.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 408825b448e3..1d000ca5d0a0 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -225,8 +225,6 @@ public static class FeatureFlagKeys /* Platform Team */ public const string WebPush = "web-push"; public const string ContentScriptIpcFramework = "content-script-ipc-channel-framework"; - public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked"; - public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users"; public const string WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins"; /* Tools Team */ From 2412e7414a698d882318620c127763380f83d010 Mon Sep 17 00:00:00 2001 From: aj-bw <81774843+aj-bw@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:01:12 -0400 Subject: [PATCH 82/85] pin image to sha (#7215) --- util/SeederApi/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/SeederApi/Dockerfile b/util/SeederApi/Dockerfile index 14f7a5bc1134..e8127591a6ec 100644 --- a/util/SeederApi/Dockerfile +++ b/util/SeederApi/Dockerfile @@ -102,7 +102,7 @@ EXPOSE 5000 # Set up health check wrapper # Get the executable and copy it to any path you want -COPY --from=ghcr.io/alexaka1/distroless-dotnet-healthchecks:1 / /healthcheck +COPY --from=ghcr.io/alexaka1/distroless-dotnet-healthchecks:1@sha256:d2a71a595020f13a9283f5e1f93c5f92bc2385c1d9b36e128a00d35863995aba / /healthcheck # Setup your healthcheck endpoints via environment variable in Dockerfile, or at runtime via `docker run -e DISTROLESS_HEALTHCHECKS_URIS__0="http://localhost/healthz" -e DISTROLESS_HEALTHCHECKS_URIS__1="http://localhost/some/other/endpoint"` ENV DISTROLESS_HEALTHCHECKS_URI="http://localhost:5000/alive" # Setup the healthcheck using the EXEC array syntax From 86e5e123fea51a4359ebebafb47da78faa24a8b3 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Mon, 16 Mar 2026 15:20:11 +0100 Subject: [PATCH 83/85] PM-33591 - Parallelize CreateUsersStep and GeneratePersonalCiphersStep (#7226) --- util/Seeder/CLAUDE.md | 19 +++++++ .../Data/Generators/CardDataGenerator.cs | 5 +- .../Generators/CipherUsernameGenerator.cs | 5 +- .../Data/Generators/IdentityDataGenerator.cs | 17 ++++-- .../Generators/SecureNoteDataGenerator.cs | 5 +- util/Seeder/Steps/CreateUsersStep.cs | 48 ++++++++++------ .../Steps/GeneratePersonalCiphersStep.cs | 57 +++++++++++++++---- 7 files changed, 119 insertions(+), 37 deletions(-) diff --git a/util/Seeder/CLAUDE.md b/util/Seeder/CLAUDE.md index ab1a00db0771..f63e45b0e0fb 100644 --- a/util/Seeder/CLAUDE.md +++ b/util/Seeder/CLAUDE.md @@ -49,6 +49,25 @@ Need to create test data? See `Pipeline/` folder for implementation. +## Parallelism + +Steps execute sequentially (phase order preserved by RecipeExecutor). Within a step, `CreateUsersStep` and `GeneratePersonalCiphersStep` use `Parallel.For` internally for CPU-bound Rust FFI work (key generation, encryption). + +**Thread-safety requirements:** +- `GeneratorContext` lazy properties (`??=`) must be force-initialized before any `Parallel.For` loop to prevent a data race +- Generators use `ThreadLocal` for thread-safe deterministic data generation +- `ManglerService` and `SeederContext` are NOT thread-safe -- pre-compute their outputs before entering parallel loops + +## Performance A/B Testing + +When measuring step-level performance changes, use paired worktrees: +- Create `server-PM-XXXXX/perf-baseline` and `server-PM-XXXXX/perf-optimized` worktrees +- Both worktrees get `Stopwatch` timing in `RecipeExecutor.Execute()` (the baseline measurement) +- Only the optimized worktree gets actual code changes +- Run presets with `--mangle` flag to avoid DB collisions between runs +- Compare per-step timings across 3+ runs each, discard the first run (JIT warmup) +- `.worktrees/` is already in `.gitignore` + ## Density Profiles Steps accept an optional `DensityProfile` that controls relationship patterns between users, groups, collections, and ciphers. When null, steps use the original round-robin behavior. When present, steps branch into density-aware algorithms. diff --git a/util/Seeder/Data/Generators/CardDataGenerator.cs b/util/Seeder/Data/Generators/CardDataGenerator.cs index 4ccbdef1a48a..e2d83602780a 100644 --- a/util/Seeder/Data/Generators/CardDataGenerator.cs +++ b/util/Seeder/Data/Generators/CardDataGenerator.cs @@ -7,6 +7,8 @@ namespace Bit.Seeder.Data.Generators; internal sealed class CardDataGenerator { + private static readonly ThreadLocal _threadFaker = new(() => new Faker()); + private readonly int _seed; private readonly GeographicRegion _region; @@ -32,7 +34,8 @@ internal CardDataGenerator(int seed, GeographicRegion region = GeographicRegion. /// internal CardViewDto GenerateByIndex(int index) { - var seededFaker = new Faker { Random = new Randomizer(_seed + index) }; + var seededFaker = _threadFaker.Value!; + seededFaker.Random = new Randomizer(_seed + index); var brands = _regionalBrands[_region]; var brand = brands[index % brands.Length]; diff --git a/util/Seeder/Data/Generators/CipherUsernameGenerator.cs b/util/Seeder/Data/Generators/CipherUsernameGenerator.cs index 407df6935925..940f3f3186e2 100644 --- a/util/Seeder/Data/Generators/CipherUsernameGenerator.cs +++ b/util/Seeder/Data/Generators/CipherUsernameGenerator.cs @@ -11,6 +11,8 @@ namespace Bit.Seeder.Data.Generators; /// internal sealed class CipherUsernameGenerator { + private static readonly ThreadLocal _threadFaker = new(() => new Faker()); + private const int NamePoolSize = 1500; private static readonly string[] PersonalEmailDomains = @@ -78,7 +80,8 @@ internal CipherUsernameGenerator( internal string GenerateByIndex(int index, int totalHint = 1000, string? domain = null) { var category = _distribution.Select(index, totalHint); - var seededFaker = new Faker { Random = new Randomizer(_seed + index) }; + var seededFaker = _threadFaker.Value!; + seededFaker.Random = new Randomizer(_seed + index); var offset = GetDeterministicOffset(index); var firstName = _firstNames[(index + offset) % _firstNames.Length]; diff --git a/util/Seeder/Data/Generators/IdentityDataGenerator.cs b/util/Seeder/Data/Generators/IdentityDataGenerator.cs index 432614c912a9..89fee6d85861 100644 --- a/util/Seeder/Data/Generators/IdentityDataGenerator.cs +++ b/util/Seeder/Data/Generators/IdentityDataGenerator.cs @@ -10,6 +10,9 @@ internal sealed class IdentityDataGenerator(int seed, GeographicRegion region = private readonly GeographicRegion _region = region; + // Instance-level (not static) because each generator needs locale-aware Faker from the constructor + private readonly ThreadLocal _threadFaker = new(() => new Faker(MapRegionToLocale(region))); + private static readonly Dictionary _regionalTitles = new() { [GeographicRegion.NorthAmerica] = ["Mr", "Mrs", "Ms", "Dr", "Prof"], @@ -26,16 +29,18 @@ internal sealed class IdentityDataGenerator(int seed, GeographicRegion region = /// internal IdentityViewDto GenerateByIndex(int index) { - var seededFaker = new Faker(MapRegionToLocale(_region)) { Random = new Randomizer(_seed + index) }; - var person = seededFaker.Person; + var seededFaker = _threadFaker.Value!; + seededFaker.Random = new Randomizer(_seed + index); var titles = _regionalTitles[_region]; + var firstName = seededFaker.Name.FirstName(); + var lastName = seededFaker.Name.LastName(); return new IdentityViewDto { Title = titles[index % titles.Length], - FirstName = person.FirstName, + FirstName = firstName, MiddleName = index % 3 == 0 ? seededFaker.Name.FirstName() : null, - LastName = person.LastName, + LastName = lastName, Address1 = seededFaker.Address.StreetAddress(), Address2 = index % 5 == 0 ? seededFaker.Address.SecondaryAddress() : null, Address3 = null, @@ -44,10 +49,10 @@ internal IdentityViewDto GenerateByIndex(int index) PostalCode = seededFaker.Address.ZipCode(), Country = GetCountryCode(seededFaker), Company = index % 2 == 0 ? seededFaker.Company.CompanyName() : null, - Email = person.Email, + Email = seededFaker.Internet.Email(firstName, lastName), Phone = seededFaker.Phone.PhoneNumber(), SSN = GenerateNationalIdByIndex(index), - Username = person.UserName, + Username = seededFaker.Internet.UserName(firstName, lastName), PassportNumber = index % 3 == 0 ? GeneratePassportNumberByIndex(index) : null, LicenseNumber = index % 2 == 0 ? GenerateLicenseNumberByIndex(index) : null }; diff --git a/util/Seeder/Data/Generators/SecureNoteDataGenerator.cs b/util/Seeder/Data/Generators/SecureNoteDataGenerator.cs index d745e2cc343e..e3d72612f4ba 100644 --- a/util/Seeder/Data/Generators/SecureNoteDataGenerator.cs +++ b/util/Seeder/Data/Generators/SecureNoteDataGenerator.cs @@ -5,6 +5,8 @@ namespace Bit.Seeder.Data.Generators; internal sealed class SecureNoteDataGenerator(int seed) { + private static readonly ThreadLocal _threadFaker = new(() => new Faker()); + private readonly int _seed = seed; private static readonly string[] _noteCategories = @@ -33,7 +35,8 @@ internal sealed class SecureNoteDataGenerator(int seed) internal (string name, string notes) GenerateByIndex(int index) { var category = _noteCategories[index % _noteCategories.Length]; - var seededFaker = new Faker { Random = new Randomizer(_seed + index) }; + var seededFaker = _threadFaker.Value!; + seededFaker.Random = new Randomizer(_seed + index); return (GenerateNoteName(category, seededFaker), GenerateNoteContent(category, seededFaker)); } diff --git a/util/Seeder/Steps/CreateUsersStep.cs b/util/Seeder/Steps/CreateUsersStep.cs index 5c2ff7764e93..0b307d77579e 100644 --- a/util/Seeder/Steps/CreateUsersStep.cs +++ b/util/Seeder/Steps/CreateUsersStep.cs @@ -24,35 +24,51 @@ public void Execute(SeederContext context) ? UserStatusDistributions.Realistic : UserStatusDistributions.AllConfirmed; - var users = new List(count); - var organizationUsers = new List(count); - var hardenedOrgUserIds = new List(); - var userDigests = new List(); var password = context.GetPassword(); + var mangler = context.GetMangler(); + var passwordHasher = context.GetPasswordHasher(); + // Pre-compute mangled emails and statuses (ManglerService is not thread-safe) + var mangledEmails = new string[count]; + var statuses = new OrganizationUserStatusType[count]; for (var i = 0; i < count; i++) { - var email = $"user{i}@{domain}"; - var mangledEmail = context.GetMangler().Mangle(email); - var userKeys = RustSdkService.GenerateUserKeys(mangledEmail, password); - var (user, _) = UserSeeder.Create(mangledEmail, context.GetPasswordHasher(), context.GetMangler(), keys: userKeys, password: password); + mangledEmails[i] = mangler.Mangle($"user{i}@{domain}"); + statuses[i] = statusDistribution.Select(i, count); + } - var status = statusDistribution.Select(i, count); + var results = new (User User, OrganizationUser OrgUser, UserKeys Keys, bool IsConfirmed)[count]; - var memberOrgKey = StatusRequiresOrgKey(status) + Parallel.For(0, count, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, i => + { + var userKeys = RustSdkService.GenerateUserKeys(mangledEmails[i], password); + var (user, _) = UserSeeder.Create(mangledEmails[i], passwordHasher, mangler, keys: userKeys, password: password); + + var memberOrgKey = StatusRequiresOrgKey(statuses[i]) ? RustSdkService.GenerateUserOrganizationKey(user.PublicKey!, orgKey) : null; var orgUser = org.CreateOrganizationUserWithKey( - user, OrganizationUserType.User, status, memberOrgKey); + user, OrganizationUserType.User, statuses[i], memberOrgKey); + + results[i] = (user, orgUser, userKeys, statuses[i] == OrganizationUserStatusType.Confirmed); + }); - users.Add(user); - organizationUsers.Add(orgUser); + var users = new List(count); + var organizationUsers = new List(count); + var hardenedOrgUserIds = new List(count); + var userDigests = new List(count); + + for (var i = 0; i < count; i++) + { + var r = results[i]; + users.Add(r.User); + organizationUsers.Add(r.OrgUser); - if (status == OrganizationUserStatusType.Confirmed) + if (r.IsConfirmed) { - hardenedOrgUserIds.Add(orgUser.Id); - userDigests.Add(new EntityRegistry.UserDigest(user.Id, orgUser.Id, userKeys.Key)); + hardenedOrgUserIds.Add(r.OrgUser.Id); + userDigests.Add(new EntityRegistry.UserDigest(r.User.Id, r.OrgUser.Id, r.Keys.Key)); } } diff --git a/util/Seeder/Steps/GeneratePersonalCiphersStep.cs b/util/Seeder/Steps/GeneratePersonalCiphersStep.cs index 31c29c3a53a2..1e9ad8e94f66 100644 --- a/util/Seeder/Steps/GeneratePersonalCiphersStep.cs +++ b/util/Seeder/Steps/GeneratePersonalCiphersStep.cs @@ -38,34 +38,67 @@ public void Execute(SeederContext context) var passwordDistribution = pwDist ?? PasswordDistributions.Realistic; var companies = Companies.All; var personalDist = density?.PersonalCipherDistribution; + var userFolderIds = context.Registry.UserFolderIds; var expectedTotal = personalDist is not null ? EstimateTotal(userDigests.Count, personalDist) : userDigests.Count * countPerUser; - var ciphers = new List(expectedTotal); - var cipherIds = new List(expectedTotal); - var globalIndex = 0; + // Force lazy generator init before parallel loop (prevents ??= data race) + _ = (generator.Username, generator.Card, generator.Identity, generator.SecureNote); - for (var userIndex = 0; userIndex < userDigests.Count; userIndex++) + // Pre-compute per-user counts and globalIndex offsets + var userCounts = new int[userDigests.Count]; + var offsets = new int[userDigests.Count]; + var runningOffset = 0; + + for (var u = 0; u < userDigests.Count; u++) { - var userDigest = userDigests[userIndex]; var userCount = countPerUser; if (personalDist is not null) { - var range = personalDist.Select(userIndex, userDigests.Count); - userCount = range.Min + (userIndex % Math.Max(range.Max - range.Min + 1, 1)); + var range = personalDist.Select(u, userDigests.Count); + userCount = range.Min + (u % Math.Max(range.Max - range.Min + 1, 1)); } - for (var i = 0; i < userCount; i++) + userCounts[u] = userCount; + offsets[u] = runningOffset; + runningOffset += userCount; + } + + var userCiphers = new Cipher[userDigests.Count][]; + + Parallel.For(0, userDigests.Count, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, u => + { + var userDigest = userDigests[u]; + var localCount = userCounts[u]; + var baseOffset = offsets[u]; + var localCiphers = new Cipher[localCount]; + + for (var i = 0; i < localCount; i++) { + var globalIndex = baseOffset + i; var cipherType = typeDistribution.Select(globalIndex, expectedTotal); var cipher = CipherComposer.Compose(globalIndex, cipherType, userDigest.SymmetricKey, companies, generator, passwordDistribution, userId: userDigest.UserId); - CipherComposer.AssignFolder(cipher, userDigest.UserId, i, context.Registry.UserFolderIds); + CipherComposer.AssignFolder(cipher, userDigest.UserId, i, userFolderIds); - ciphers.Add(cipher); - cipherIds.Add(cipher.Id); - globalIndex++; + localCiphers[i] = cipher; + } + + userCiphers[u] = localCiphers; + }); + + // Flatten jagged array into context lists + var ciphers = new List(expectedTotal); + var cipherIds = new List(expectedTotal); + + for (var u = 0; u < userDigests.Count; u++) + { + var localCiphers = userCiphers[u]; + for (var i = 0; i < localCiphers.Length; i++) + { + ciphers.Add(localCiphers[i]); + cipherIds.Add(localCiphers[i].Id); } } From 276a218982a1612462f99bcbad9d14dd5bbeb93e Mon Sep 17 00:00:00 2001 From: sven-bitwarden Date: Mon, 16 Mar 2026 11:13:29 -0500 Subject: [PATCH 84/85] [PM-31923] Remove Unused Sprocs (#7060) * Remove old/unused sprocs * Consistency --- ...llectionUser_ReadByOrganizationUserIds.sql | 19 ---- ...serUserDetails_ReadWithCollectionsById.sql | 23 ----- ...on_ReadByOrganizationIdWithPermissions.sql | 86 ------------------- ...-02-23_00_RemoveUnusedCollectionSprocs.sql | 17 ++++ 4 files changed, 17 insertions(+), 128 deletions(-) delete mode 100644 src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationUserIds.sql delete mode 100644 src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadWithCollectionsById.sql delete mode 100644 src/Sql/dbo/Vault/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql create mode 100644 util/Migrator/DbScripts/2026-02-23_00_RemoveUnusedCollectionSprocs.sql diff --git a/src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationUserIds.sql b/src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationUserIds.sql deleted file mode 100644 index b3cf499f7765..000000000000 --- a/src/Sql/dbo/Stored Procedures/CollectionUser_ReadByOrganizationUserIds.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE PROCEDURE [dbo].[CollectionUser_ReadByOrganizationUserIds] - @OrganizationUserIds [dbo].[GuidIdArray] READONLY -AS -BEGIN - SET NOCOUNT ON - - SELECT - CU.* - FROM - [dbo].[OrganizationUser] OU - INNER JOIN - [dbo].[CollectionUser] CU ON CU.[OrganizationUserId] = OU.[Id] - INNER JOIN - [dbo].[Collection] C ON CU.[CollectionId] = C.[Id] - INNER JOIN - @OrganizationUserIds OUI ON OUI.[Id] = OU.[Id] - WHERE - C.[Type] != 1 -- Exclude DefaultUserCollection -END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadWithCollectionsById.sql b/src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadWithCollectionsById.sql deleted file mode 100644 index ed683d8392d9..000000000000 --- a/src/Sql/dbo/Stored Procedures/OrganizationUserUserDetails_ReadWithCollectionsById.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE PROCEDURE [dbo].[OrganizationUserUserDetails_ReadWithCollectionsById] - @Id UNIQUEIDENTIFIER -AS -BEGIN - SET NOCOUNT ON - - EXEC [OrganizationUserUserDetails_ReadById] @Id - - SELECT - CU.[CollectionId] Id, - CU.[ReadOnly], - CU.[HidePasswords], - CU.[Manage] - FROM - [dbo].[OrganizationUser] OU - INNER JOIN - [dbo].[CollectionUser] CU ON CU.[OrganizationUserId] = [OU].[Id] - INNER JOIN - [dbo].[Collection] C ON CU.[CollectionId] = C.[Id] - WHERE - [OrganizationUserId] = @Id - AND C.[Type] != 1 -- Exclude default user collections -END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql b/src/Sql/dbo/Vault/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql deleted file mode 100644 index bd8d48b29bee..000000000000 --- a/src/Sql/dbo/Vault/Stored Procedures/Collections/Collection_ReadByOrganizationIdWithPermissions.sql +++ /dev/null @@ -1,86 +0,0 @@ -CREATE PROCEDURE [dbo].[Collection_ReadByOrganizationIdWithPermissions] - @OrganizationId UNIQUEIDENTIFIER, - @UserId UNIQUEIDENTIFIER, - @IncludeAccessRelationships BIT -AS -BEGIN - SET NOCOUNT ON - - SELECT - C.*, - MIN(CASE - WHEN - COALESCE(CU.[ReadOnly], CG.[ReadOnly], 0) = 0 - THEN 0 - ELSE 1 - END) AS [ReadOnly], - MIN(CASE - WHEN - COALESCE(CU.[HidePasswords], CG.[HidePasswords], 0) = 0 - THEN 0 - ELSE 1 - END) AS [HidePasswords], - MAX(CASE - WHEN - COALESCE(CU.[Manage], CG.[Manage], 0) = 0 - THEN 0 - ELSE 1 - END) AS [Manage], - MAX(CASE - WHEN - CU.[CollectionId] IS NULL AND CG.[CollectionId] IS NULL - THEN 0 - ELSE 1 - END) AS [Assigned], - CASE - WHEN - -- No user or group has manage rights - NOT EXISTS( - SELECT 1 - FROM [dbo].[CollectionUser] CU2 - JOIN [dbo].[OrganizationUser] OU2 ON CU2.[OrganizationUserId] = OU2.[Id] - WHERE - CU2.[CollectionId] = C.[Id] AND - CU2.[Manage] = 1 - ) - AND NOT EXISTS ( - SELECT 1 - FROM [dbo].[CollectionGroup] CG2 - WHERE - CG2.[CollectionId] = C.[Id] AND - CG2.[Manage] = 1 - ) - THEN 1 - ELSE 0 - END AS [Unmanaged] - FROM - [dbo].[CollectionView] C - LEFT JOIN - [dbo].[OrganizationUser] OU ON C.[OrganizationId] = OU.[OrganizationId] AND OU.[UserId] = @UserId - LEFT JOIN - [dbo].[CollectionUser] CU ON CU.[CollectionId] = C.[Id] AND CU.[OrganizationUserId] = [OU].[Id] - LEFT JOIN - [dbo].[GroupUser] GU ON CU.[CollectionId] IS NULL AND GU.[OrganizationUserId] = OU.[Id] - LEFT JOIN - [dbo].[Group] G ON G.[Id] = GU.[GroupId] - LEFT JOIN - [dbo].[CollectionGroup] CG ON CG.[CollectionId] = C.[Id] AND CG.[GroupId] = GU.[GroupId] - WHERE - C.[OrganizationId] = @OrganizationId AND - C.[Type] != 1 -- Exclude DefaultUserCollection - GROUP BY - C.[Id], - C.[OrganizationId], - C.[Name], - C.[CreationDate], - C.[RevisionDate], - C.[ExternalId], - C.[DefaultUserCollectionEmail], - C.[Type] - - IF (@IncludeAccessRelationships = 1) - BEGIN - EXEC [dbo].[CollectionGroup_ReadByOrganizationId] @OrganizationId - EXEC [dbo].[CollectionUser_ReadByOrganizationId] @OrganizationId - END -END diff --git a/util/Migrator/DbScripts/2026-02-23_00_RemoveUnusedCollectionSprocs.sql b/util/Migrator/DbScripts/2026-02-23_00_RemoveUnusedCollectionSprocs.sql new file mode 100644 index 000000000000..c17b78c9d973 --- /dev/null +++ b/util/Migrator/DbScripts/2026-02-23_00_RemoveUnusedCollectionSprocs.sql @@ -0,0 +1,17 @@ +IF OBJECT_ID('[dbo].[CollectionUser_ReadByOrganizationUserIds]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[CollectionUser_ReadByOrganizationUserIds] +END +GO + +IF OBJECT_ID('[dbo].[OrganizationUserUserDetails_ReadWithCollectionsById]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationUserUserDetails_ReadWithCollectionsById] +END +GO + +IF OBJECT_ID('[dbo].[Collection_ReadByOrganizationIdWithPermissions]') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[Collection_ReadByOrganizationIdWithPermissions] +END +GO From 24d9065d04ecd68352d5b39fe0e2f3a355f05d53 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Mon, 16 Mar 2026 13:47:12 -0500 Subject: [PATCH 85/85] PM-31923 fixing fileData validation check --- src/Api/Dirt/Controllers/OrganizationReportsController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index 4cdb576a4443..e83fb3633251 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -106,7 +106,7 @@ public async Task GetLatestOrganizationReportAsync(Guid organizat var response = new OrganizationReportResponseModel(latestReport); var fileData = latestReport.GetReportFile(); - if (fileData != null) + if (fileData is { Validated: true }) { response.ReportFileDownloadUrl = await _storageService.GetReportDataDownloadUrlAsync(latestReport, fileData); }