From 99bc6414510919a48662431d6b14ee4223714fed Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Wed, 25 Feb 2026 08:44:00 -0600 Subject: [PATCH 1/2] pm-31920 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 65092cbef98b764697065992e2a1a1bc22606fe6 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Thu, 26 Feb 2026 07:29:14 -0600 Subject: [PATCH 2/2] pm-31920 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); + } }