From 99bc6414510919a48662431d6b14ee4223714fed Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Wed, 25 Feb 2026 08:44:00 -0600 Subject: [PATCH 01/13] 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 02/13] 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); + } } From 5852cba845659bfb4fb3c8e494a49c89dd77d02a Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Wed, 4 Mar 2026 09:43:50 -0600 Subject: [PATCH 03/13] 31920 updating code to now use the ReportFile field --- .../OrganizationReportsController.cs | 245 ++++++++++++++++-- .../OrganizationReportsV2Controller.cs | 191 -------------- .../OrganizationReportResponseModel.cs | 2 + .../OrganizationReportV2ResponseModel.cs | 5 +- src/Core/Constants.cs | 1 + src/Core/Dirt/Entities/OrganizationReport.cs | 9 +- src/Core/Dirt/Enums/OrganizationReportType.cs | 7 - .../Models/Data/OrganizationReportFileData.cs | 20 -- src/Core/Dirt/Models/Data/ReportFile.cs | 32 +++ .../CreateOrganizationReportV2Command.cs | 5 +- .../IValidateOrganizationReportFileCommand.cs | 8 + .../ReportingServiceCollectionExtensions.cs | 1 + .../ValidateOrganizationReportFileCommand.cs | 51 ++++ .../AzureOrganizationReportStorageService.cs | 28 +- .../IOrganizationReportStorageService.cs | 10 +- .../LocalOrganizationReportStorageService.cs | 10 +- .../NoopOrganizationReportStorageService.cs | 10 +- .../OrganizationReportResponseModelTests.cs | 35 +++ .../OrganizationReportsControllerTests.cs | 10 +- .../CreateOrganizationReportV2CommandTests.cs | 6 +- .../GetOrganizationReportDataV2QueryTests.cs | 14 +- ...ateOrganizationReportDataV2CommandTests.cs | 7 +- ...idateOrganizationReportFileCommandTests.cs | 154 +++++++++++ ...reOrganizationReportStorageServiceTests.cs | 17 +- ...alOrganizationReportStorageServiceTests.cs | 10 +- 25 files changed, 588 insertions(+), 300 deletions(-) delete mode 100644 src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs delete mode 100644 src/Core/Dirt/Enums/OrganizationReportType.cs delete mode 100644 src/Core/Dirt/Models/Data/OrganizationReportFileData.cs create mode 100644 src/Core/Dirt/Models/Data/ReportFile.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IValidateOrganizationReportFileCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs create mode 100644 test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index fc9a1b2d84a0..65e702c64655 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -1,8 +1,15 @@ -using Bit.Api.Dirt.Models.Response; +using System.Text.Json; +using Bit.Api.Dirt.Models.Response; +using Bit.Api.Utilities; +using Bit.Core; using Bit.Core.Context; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -23,6 +30,15 @@ public class OrganizationReportsController : Controller private readonly IUpdateOrganizationReportDataCommand _updateOrganizationReportDataCommand; private readonly IGetOrganizationReportApplicationDataQuery _getOrganizationReportApplicationDataQuery; private readonly IUpdateOrganizationReportApplicationDataCommand _updateOrganizationReportApplicationDataCommand; + private readonly IFeatureService _featureService; + private readonly IApplicationCacheService _applicationCacheService; + private readonly IOrganizationReportStorageService _storageService; + private readonly ICreateOrganizationReportV2Command _createV2Command; + private readonly IUpdateOrganizationReportDataV2Command _updateDataV2Command; + private readonly IGetOrganizationReportDataV2Query _getDataV2Query; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IValidateOrganizationReportFileCommand _validateCommand; + private readonly ILogger _logger; public OrganizationReportsController( ICurrentContext currentContext, @@ -35,8 +51,16 @@ public OrganizationReportsController( IGetOrganizationReportDataQuery getOrganizationReportDataQuery, IUpdateOrganizationReportDataCommand updateOrganizationReportDataCommand, IGetOrganizationReportApplicationDataQuery getOrganizationReportApplicationDataQuery, - IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicationDataCommand - ) + IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicationDataCommand, + IFeatureService featureService, + IApplicationCacheService applicationCacheService, + IOrganizationReportStorageService storageService, + ICreateOrganizationReportV2Command createV2Command, + IUpdateOrganizationReportDataV2Command updateDataV2Command, + IGetOrganizationReportDataV2Query getDataV2Query, + IOrganizationReportRepository organizationReportRepo, + IValidateOrganizationReportFileCommand validateCommand, + ILogger logger) { _currentContext = currentContext; _getOrganizationReportQuery = getOrganizationReportQuery; @@ -49,10 +73,17 @@ IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicat _updateOrganizationReportDataCommand = updateOrganizationReportDataCommand; _getOrganizationReportApplicationDataQuery = getOrganizationReportApplicationDataQuery; _updateOrganizationReportApplicationDataCommand = updateOrganizationReportApplicationDataCommand; + _featureService = featureService; + _applicationCacheService = applicationCacheService; + _storageService = storageService; + _createV2Command = createV2Command; + _updateDataV2Command = updateDataV2Command; + _getDataV2Query = getDataV2Query; + _organizationReportRepo = organizationReportRepo; + _validateCommand = validateCommand; + _logger = logger; } - #region Whole OrganizationReport Endpoints - [HttpGet("{organizationId}/latest")] public async Task GetLatestOrganizationReportAsync(Guid organizationId) { @@ -70,29 +101,70 @@ public async Task GetLatestOrganizationReportAsync(Guid organizat [HttpGet("{organizationId}/{reportId}")] public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) { + if (_featureService.IsEnabled(FeatureFlagKeys.WholeReportDataFileStorage)) + { + await AuthorizeV2Async(organizationId); + + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + + if (report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } + + return Ok(new OrganizationReportResponseModel(report)); + } + if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); } - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + var v1Report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - if (report == null) + if (v1Report == null) { throw new NotFoundException("Report not found for the specified organization."); } - if (report.OrganizationId != organizationId) + if (v1Report.OrganizationId != organizationId) { throw new BadRequestException("Invalid report ID"); } - return Ok(report); + return Ok(v1Report); } [HttpPost("{organizationId}")] - public async Task CreateOrganizationReportAsync(Guid organizationId, [FromBody] AddOrganizationReportRequest request) + public async Task CreateOrganizationReportAsync( + Guid organizationId, + [FromBody] AddOrganizationReportRequest request) { + if (_featureService.IsEnabled(FeatureFlagKeys.WholeReportDataFileStorage)) + { + if (organizationId == Guid.Empty) + { + throw new BadRequestException("Organization ID is required."); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + await AuthorizeV2Async(organizationId); + + var report = await _createV2Command.CreateAsync(request); + var fileData = report.GetReportFileData()!; + + return Ok(new OrganizationReportV2ResponseModel + { + ReportDataUploadUrl = await _storageService.GetReportDataUploadUrlAsync(report, fileData), + ReportResponse = new OrganizationReportResponseModel(report), + FileUploadType = _storageService.FileUploadType + }); + } + if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); @@ -103,8 +175,8 @@ public async Task CreateOrganizationReportAsync(Guid organization throw new BadRequestException("Organization ID in the request body must match the route parameter"); } - var report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request); - var response = report == null ? null : new OrganizationReportResponseModel(report); + var v1Report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request); + var response = v1Report == null ? null : new OrganizationReportResponseModel(v1Report); return Ok(response); } @@ -126,10 +198,6 @@ public async Task UpdateOrganizationReportAsync(Guid organization return Ok(response); } - #endregion - - # region SummaryData Field Endpoints - [HttpGet("{organizationId}/data/summary")] public async Task GetOrganizationReportSummaryDataByDateRangeAsync( Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) @@ -191,9 +259,6 @@ public async Task UpdateOrganizationReportSummaryAsync(Guid organ return Ok(response); } - #endregion - - #region ReportData Field Endpoints [HttpGet("{organizationId}/data/report/{reportId}")] public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) @@ -214,8 +279,37 @@ public async Task GetOrganizationReportDataAsync(Guid organizatio } [HttpPatch("{organizationId}/data/report/{reportId}")] - public async Task UpdateOrganizationReportDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportDataRequest request) + public async Task UpdateOrganizationReportDataAsync( + Guid organizationId, + Guid reportId, + [FromBody] UpdateOrganizationReportDataRequest request, + [FromQuery] string? reportFileId) { + if (_featureService.IsEnabled(FeatureFlagKeys.WholeReportDataFileStorage)) + { + if (request.OrganizationId != organizationId || request.ReportId != reportId) + { + throw new BadRequestException("Organization ID and Report ID must match route parameters"); + } + + if (string.IsNullOrEmpty(reportFileId)) + { + throw new BadRequestException("ReportFileId query parameter is required"); + } + + await AuthorizeV2Async(organizationId); + + var uploadUrl = await _updateDataV2Command.GetUploadUrlAsync(request, reportFileId); + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + + return Ok(new OrganizationReportV2ResponseModel + { + ReportDataUploadUrl = uploadUrl, + ReportResponse = new OrganizationReportResponseModel(report), + FileUploadType = _storageService.FileUploadType + }); + } + if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); @@ -237,10 +331,6 @@ public async Task UpdateOrganizationReportDataAsync(Guid organiza return Ok(response); } - #endregion - - #region ApplicationData Field Endpoints - [HttpGet("{organizationId}/data/application/{reportId}")] public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) { @@ -297,5 +387,110 @@ public async Task UpdateOrganizationReportApplicationDataAsync(Gu } } - #endregion + [RequireFeature(FeatureFlagKeys.WholeReportDataFileStorage)] + [HttpPost("{organizationId}/{reportId}/file/report-data")] + [SelfHosted(SelfHostedOnly = true)] + [RequestSizeLimit(Constants.FileSize501mb)] + [DisableFormValueModelBinding] + public async Task UploadReportDataAsync(Guid organizationId, Guid reportId, [FromQuery] string reportFileId) + { + await AuthorizeV2Async(organizationId); + + if (!Request?.ContentType?.Contains("multipart/") ?? true) + { + throw new BadRequestException("Invalid contenwt."); + } + + if (string.IsNullOrEmpty(reportFileId)) + { + throw new BadRequestException("ReportFileId query parameter is required"); + } + + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + if (report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } + + var fileData = report.GetReportFileData(); + if (fileData == null || fileData.Id != reportFileId) + { + throw new NotFoundException(); + } + + await Request.GetFileAsync(async (stream) => + { + await _storageService.UploadReportDataAsync(report, fileData, stream); + }); + + var (valid, length) = await _storageService.ValidateFileAsync(report, fileData, 0, Constants.FileSize501mb); + if (!valid) + { + throw new BadRequestException("File received does not match expected constraints."); + } + + fileData.Validated = true; + fileData.Size = length; + report.SetReportFileData(fileData); + report.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(report); + } + + [AllowAnonymous] + [RequireFeature(FeatureFlagKeys.WholeReportDataFileStorage)] + [HttpPost("file/validate/azure")] + public async Task AzureValidateFile() + { + return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> + { + { + "Microsoft.Storage.BlobCreated", async (eventGridEvent) => + { + try + { + var blobName = + eventGridEvent.Subject.Split($"{AzureOrganizationReportStorageService.ContainerName}/blobs/")[1]; + var reportId = AzureOrganizationReportStorageService.ReportIdFromBlobName(blobName); + var report = await _organizationReportRepo.GetByIdAsync(new Guid(reportId)); + if (report == null) + { + if (_storageService is AzureOrganizationReportStorageService azureStorageService) + { + await azureStorageService.DeleteBlobAsync(blobName); + } + + return; + } + + var fileData = report.GetReportFileData(); + if (fileData == null) + { + return; + } + + await _validateCommand.ValidateAsync(report, fileData.Id!); + } + catch (Exception e) + { + _logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", + JsonSerializer.Serialize(eventGridEvent)); + } + } + } + }); + } + + private async Task AuthorizeV2Async(Guid organizationId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); + if (orgAbility is null || !orgAbility.UseRiskInsights) + { + throw new BadRequestException("Your organization's plan does not support this feature."); + } + } } diff --git a/src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs b/src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs deleted file mode 100644 index 13e76734b0c3..000000000000 --- a/src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs +++ /dev/null @@ -1,191 +0,0 @@ -using Bit.Api.Dirt.Models.Response; -using Bit.Api.Utilities; -using Bit.Core; -using Bit.Core.Context; -using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -using Bit.Core.Dirt.Reports.Services; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Bit.Core.Services; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Bit.Api.Dirt.Controllers; - -[Route("reports/v2/organizations")] -[Authorize("Application")] -public class OrganizationReportsV2Controller : Controller -{ - private readonly ICurrentContext _currentContext; - private readonly IApplicationCacheService _applicationCacheService; - private readonly IOrganizationReportStorageService _storageService; - private readonly ICreateOrganizationReportV2Command _createCommand; - private readonly IUpdateOrganizationReportDataV2Command _updateDataCommand; - private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; - private readonly IGetOrganizationReportDataV2Query _getDataQuery; - private readonly IUpdateOrganizationReportCommand _updateOrganizationReportCommand; - private readonly IOrganizationReportRepository _organizationReportRepo; - - public OrganizationReportsV2Controller( - ICurrentContext currentContext, - IApplicationCacheService applicationCacheService, - IOrganizationReportStorageService storageService, - ICreateOrganizationReportV2Command createCommand, - IUpdateOrganizationReportDataV2Command updateDataCommand, - IGetOrganizationReportQuery getOrganizationReportQuery, - IGetOrganizationReportDataV2Query getDataQuery, - IUpdateOrganizationReportCommand updateOrganizationReportCommand, - IOrganizationReportRepository organizationReportRepo) - { - _currentContext = currentContext; - _applicationCacheService = applicationCacheService; - _storageService = storageService; - _createCommand = createCommand; - _updateDataCommand = updateDataCommand; - _getOrganizationReportQuery = getOrganizationReportQuery; - _getDataQuery = getDataQuery; - _updateOrganizationReportCommand = updateOrganizationReportCommand; - _organizationReportRepo = organizationReportRepo; - } - - private async Task AuthorizeAsync(Guid organizationId) - { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } - - var orgAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId); - if (orgAbility is null || !orgAbility.UseRiskInsights) - { - throw new BadRequestException("Your organization's plan does not support this feature."); - } - } - - [HttpPost("{organizationId}")] - public async Task CreateOrganizationReportAsync( - Guid organizationId, - [FromBody] AddOrganizationReportRequest request) - { - if (organizationId == Guid.Empty) - { - throw new BadRequestException("Organization ID is required."); - } - - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - - await AuthorizeAsync(organizationId); - - var report = await _createCommand.CreateAsync(request); - - var fileData = report.GetReportFileData()!; - - return new OrganizationReportV2ResponseModel - { - ReportDataUploadUrl = await _storageService.GetReportDataUploadUrlAsync(report, fileData), - ReportResponse = new OrganizationReportResponseModel(report), - FileUploadType = _storageService.FileUploadType - }; - } - - [HttpGet("{organizationId}/{reportId}")] - public async Task GetOrganizationReportAsync( - Guid organizationId, - Guid reportId) - { - await AuthorizeAsync(organizationId); - - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - - if (report.OrganizationId != organizationId) - { - throw new BadRequestException("Invalid report ID"); - } - - return new OrganizationReportResponseModel(report); - } - - [HttpPatch("{organizationId}/data/report/{reportId}")] - public async Task GetReportDataUploadUrlAsync( - Guid organizationId, - Guid reportId, - [FromBody] UpdateOrganizationReportDataRequest request, - [FromQuery] string reportFileId) - { - if (request.OrganizationId != organizationId || request.ReportId != reportId) - { - throw new BadRequestException("Organization ID and Report ID must match route parameters"); - } - - if (string.IsNullOrEmpty(reportFileId)) - { - throw new BadRequestException("ReportFileId query parameter is required"); - } - - await AuthorizeAsync(organizationId); - - var uploadUrl = await _updateDataCommand.GetUploadUrlAsync(request, reportFileId); - - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - - return new OrganizationReportV2ResponseModel - { - ReportDataUploadUrl = uploadUrl, - ReportResponse = new OrganizationReportResponseModel(report), - FileUploadType = _storageService.FileUploadType - }; - } - - [HttpPost("{organizationId}/{reportId}/file/report-data")] - [SelfHosted(SelfHostedOnly = true)] - [RequestSizeLimit(Constants.FileSize501mb)] - [DisableFormValueModelBinding] - public async Task UploadReportDataAsync(Guid organizationId, Guid reportId, [FromQuery] string reportFileId) - { - await AuthorizeAsync(organizationId); - - if (!Request?.ContentType?.Contains("multipart/") ?? true) - { - throw new BadRequestException("Invalid content."); - } - - if (string.IsNullOrEmpty(reportFileId)) - { - throw new BadRequestException("ReportFileId query parameter is required"); - } - - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - if (report.OrganizationId != organizationId) - { - throw new BadRequestException("Invalid report ID"); - } - - var fileData = report.GetReportFileData(); - if (fileData == null || fileData.Id != reportFileId) - { - throw new NotFoundException(); - } - - await Request.GetFileAsync(async (stream) => - { - await _storageService.UploadReportDataAsync(report, fileData, stream); - }); - - var (valid, length) = await _storageService.ValidateFileAsync(report, fileData, 0, Constants.FileSize501mb); - if (!valid) - { - throw new BadRequestException("File received does not match expected constraints."); - } - - fileData.Validated = true; - fileData.Size = length; - report.SetReportFileData(fileData); - report.RevisionDate = DateTime.UtcNow; - await _organizationReportRepo.ReplaceAsync(report); - } -} diff --git a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs index e477e5b806a7..c210303c5b2e 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs @@ -1,4 +1,5 @@ using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; namespace Bit.Api.Dirt.Models.Response; @@ -13,6 +14,7 @@ public class OrganizationReportResponseModel public int? PasswordCount { get; set; } public int? PasswordAtRiskCount { get; set; } public int? MemberCount { get; set; } + public ReportFile? File { get; set; } public DateTime? CreationDate { get; set; } = null; public DateTime? RevisionDate { get; set; } = null; diff --git a/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs index afadcd2f8db9..63f73d07b4b8 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs @@ -1,11 +1,10 @@ using Bit.Core.Enums; -using Bit.Core.Models.Api; namespace Bit.Api.Dirt.Models.Response; -public class OrganizationReportV2ResponseModel : ResponseModel +public class OrganizationReportV2ResponseModel { - public OrganizationReportV2ResponseModel() : base("organizationReport-v2") { } + public OrganizationReportV2ResponseModel() { } public string ReportDataUploadUrl { get; set; } = string.Empty; public OrganizationReportResponseModel ReportResponse { get; set; } = null!; diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 2eef68cb4243..b07271cea132 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -268,6 +268,7 @@ public static class FeatureFlagKeys public const string ArchiveVaultItems = "pm-19148-innovation-archive"; /* DIRT Team */ + public const string WholeReportDataFileStorage = "pm-31920-whole-report-data-file-storage"; public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging"; public const string EventManagementForHuntress = "event-management-for-huntress"; diff --git a/src/Core/Dirt/Entities/OrganizationReport.cs b/src/Core/Dirt/Entities/OrganizationReport.cs index 81c9dd6e500a..d20b5772c66e 100644 --- a/src/Core/Dirt/Entities/OrganizationReport.cs +++ b/src/Core/Dirt/Entities/OrganizationReport.cs @@ -1,7 +1,6 @@ #nullable enable using System.Text.Json; -using Bit.Core.Dirt.Enums; using Bit.Core.Dirt.Models.Data; using Bit.Core.Entities; using Bit.Core.Utilities; @@ -30,19 +29,17 @@ public class OrganizationReport : ITableObject public int? PasswordAtRiskCount { get; set; } public int? CriticalPasswordCount { get; set; } public int? CriticalPasswordAtRiskCount { get; set; } - public OrganizationReportType Type { get; set; } - - public OrganizationReportFileData? GetReportFileData() + public ReportFile? GetReportFileData() { if (string.IsNullOrWhiteSpace(ReportData)) { return null; } - return JsonSerializer.Deserialize(ReportData); + return JsonSerializer.Deserialize(ReportData); } - public void SetReportFileData(OrganizationReportFileData data) + public void SetReportFileData(ReportFile data) { ReportData = JsonSerializer.Serialize(data, JsonHelpers.IgnoreWritingNull); } diff --git a/src/Core/Dirt/Enums/OrganizationReportType.cs b/src/Core/Dirt/Enums/OrganizationReportType.cs deleted file mode 100644 index ea6317180524..000000000000 --- a/src/Core/Dirt/Enums/OrganizationReportType.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bit.Core.Dirt.Enums; - -public enum OrganizationReportType : byte -{ - Data = 0, - File = 1 -} diff --git a/src/Core/Dirt/Models/Data/OrganizationReportFileData.cs b/src/Core/Dirt/Models/Data/OrganizationReportFileData.cs deleted file mode 100644 index 78c651867d45..000000000000 --- a/src/Core/Dirt/Models/Data/OrganizationReportFileData.cs +++ /dev/null @@ -1,20 +0,0 @@ -#nullable enable - -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; -using static System.Text.Json.Serialization.JsonNumberHandling; - -namespace Bit.Core.Dirt.Models.Data; - -public class OrganizationReportFileData -{ - [JsonNumberHandling(WriteAsString | AllowReadingFromString)] - public long Size { get; set; } - - [DisallowNull] - public string? Id { get; set; } - - public string FileName { get; set; } = "report-data.json"; - - public bool Validated { get; set; } -} diff --git a/src/Core/Dirt/Models/Data/ReportFile.cs b/src/Core/Dirt/Models/Data/ReportFile.cs new file mode 100644 index 000000000000..e9106657d3cc --- /dev/null +++ b/src/Core/Dirt/Models/Data/ReportFile.cs @@ -0,0 +1,32 @@ +#nullable enable + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using static System.Text.Json.Serialization.JsonNumberHandling; + +namespace Bit.Core.Dirt.Models.Data; + +/// +/// Metadata about a file-backed organization report stored in blob storage. +/// Serialized into . +/// +public class ReportFile +{ + /// Validated byte-length of the blob (set after upload validation). + [JsonNumberHandling(WriteAsString | AllowReadingFromString)] + public long Size { get; set; } + + /// Random token that forms part of the blob path. + [DisallowNull] + public string? Id { get; set; } + + /// Leaf file name inside the blob path. + public string FileName { get; set; } = string.Empty; + + /// + /// Whether the blob has been validated after upload. + /// Cloud uploads start false and are set to true by the Event Grid webhook. + /// Self-hosted uploads are validated inline and default to true. + /// + public bool Validated { get; set; } = true; +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs index 54ce4070f2d8..135e4b09dab6 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs @@ -1,5 +1,4 @@ using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Enums; using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; @@ -41,16 +40,16 @@ public async Task CreateAsync(AddOrganizationReportRequest r throw new BadRequestException(errorMessage); } - var fileData = new OrganizationReportFileData + var fileData = new ReportFile { Id = CoreHelpers.SecureRandomString(32, upper: false, special: false), + FileName = "report-data.json", Validated = false }; var organizationReport = new OrganizationReport { OrganizationId = request.OrganizationId, - Type = OrganizationReportType.File, CreationDate = DateTime.UtcNow, ContentEncryptionKey = request.ContentEncryptionKey ?? string.Empty, SummaryData = request.SummaryData, diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IValidateOrganizationReportFileCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IValidateOrganizationReportFileCommand.cs new file mode 100644 index 000000000000..bba11a205966 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IValidateOrganizationReportFileCommand.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Entities; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IValidateOrganizationReportFileCommand +{ + Task ValidateAsync(OrganizationReport report, string reportFileId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index 0331d2ffff8c..4471ea8c3c01 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -27,6 +27,7 @@ public static void AddReportingServices(this IServiceCollection services) // v2 file storage commands services.AddScoped(); services.AddScoped(); + services.AddScoped(); // v2 file storage queries services.AddScoped(); diff --git a/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs new file mode 100644 index 000000000000..a78c6880608d --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs @@ -0,0 +1,51 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class ValidateOrganizationReportFileCommand : IValidateOrganizationReportFileCommand +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IOrganizationReportStorageService _storageService; + private readonly ILogger _logger; + + public ValidateOrganizationReportFileCommand( + IOrganizationReportRepository organizationReportRepo, + IOrganizationReportStorageService storageService, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _storageService = storageService; + _logger = logger; + } + + public async Task ValidateAsync(OrganizationReport report, string reportFileId) + { + var fileData = report.GetReportFileData(); + if (fileData == null || fileData.Id != reportFileId) + { + return false; + } + + var (valid, length) = await _storageService.ValidateFileAsync(report, fileData, 0, Constants.FileSize501mb); + if (!valid) + { + _logger.LogWarning( + "Deleted report {ReportId} because its file size {Size} was invalid.", + report.Id, length); + await _storageService.DeleteReportFilesAsync(report, reportFileId); + await _organizationReportRepo.DeleteAsync(report); + return false; + } + + fileData.Validated = true; + fileData.Size = length; + report.SetReportFileData(fileData); + report.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(report); + return true; + } +} diff --git a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs index 8698c87087e0..4580b7c1fbd1 100644 --- a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs @@ -20,6 +20,8 @@ public class AzureOrganizationReportStorageService : IOrganizationReportStorageS public FileUploadType FileUploadType => FileUploadType.Azure; + public static string ReportIdFromBlobName(string blobName) => blobName.Split('/')[2]; + public AzureOrganizationReportStorageService( GlobalSettings globalSettings, ILogger logger) @@ -28,7 +30,7 @@ public AzureOrganizationReportStorageService( _logger = logger; } - public async Task GetReportDataUploadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) + public async Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData) { await InitAsync(); var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); @@ -37,7 +39,7 @@ public async Task GetReportDataUploadUrlAsync(OrganizationReport report, DateTime.UtcNow.Add(_sasTokenLifetime)).ToString(); } - public async Task GetReportDataDownloadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) + public async Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData) { await InitAsync(); var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); @@ -45,7 +47,7 @@ public async Task GetReportDataDownloadUrlAsync(OrganizationReport repor DateTime.UtcNow.Add(_sasTokenLifetime)).ToString(); } - public async Task UploadReportDataAsync(OrganizationReport report, OrganizationReportFileData fileData, Stream stream) + public async Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream) { await InitAsync(); var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); @@ -53,7 +55,7 @@ public async Task UploadReportDataAsync(OrganizationReport report, OrganizationR } public async Task<(bool valid, long length)> ValidateFileAsync( - OrganizationReport report, OrganizationReportFileData fileData, long minimum, long maximum) + OrganizationReport report, ReportFile fileData, long minimum, long maximum) { await InitAsync(); @@ -84,6 +86,24 @@ public async Task UploadReportDataAsync(OrganizationReport report, OrganizationR } } + public async Task DeleteBlobAsync(string blobName) + { + await InitAsync(); + var blobClient = _containerClient!.GetBlobClient(blobName); + await blobClient.DeleteIfExistsAsync(); + } + + public async Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId) + { + await InitAsync(); + var prefix = $"{report.OrganizationId}/{report.CreationDate:MM-dd-yyyy}/{report.Id}/{reportFileId}/"; + await foreach (var blobItem in _containerClient!.GetBlobsAsync(prefix: prefix)) + { + var blobClient = _containerClient.GetBlobClient(blobItem.Name); + await blobClient.DeleteIfExistsAsync(); + } + } + private static string BlobPath(OrganizationReport report, string fileId, string fileName) { var date = report.CreationDate.ToString("MM-dd-yyyy"); diff --git a/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs index 948239685a68..bae2eb793aee 100644 --- a/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs @@ -8,11 +8,13 @@ public interface IOrganizationReportStorageService { FileUploadType FileUploadType { get; } - Task GetReportDataUploadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData); + Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData); - Task GetReportDataDownloadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData); + Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData); - Task UploadReportDataAsync(OrganizationReport report, OrganizationReportFileData fileData, Stream stream); + Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream); - Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, OrganizationReportFileData fileData, long minimum, long maximum); + Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, ReportFile fileData, long minimum, long maximum); + + Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId); } diff --git a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs index 0c827da35521..98a07d86006d 100644 --- a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs @@ -18,20 +18,20 @@ public LocalOrganizationReportStorageService(GlobalSettings globalSettings) _baseUrl = globalSettings.OrganizationReport.BaseUrl; } - public Task GetReportDataUploadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) - => Task.FromResult($"/reports/v2/organizations/{report.OrganizationId}/{report.Id}/file/report-data"); + public Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData) + => Task.FromResult($"/reports/organizations/{report.OrganizationId}/{report.Id}/file/report-data"); - public Task GetReportDataDownloadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) + public Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData) { InitDir(); return Task.FromResult($"{_baseUrl}/{RelativePath(report, fileData.Id!, fileData.FileName)}"); } - public async Task UploadReportDataAsync(OrganizationReport report, OrganizationReportFileData fileData, Stream stream) + public async Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream) => await WriteFileAsync(report, fileData.Id!, fileData.FileName, stream); public Task<(bool valid, long length)> ValidateFileAsync( - OrganizationReport report, OrganizationReportFileData fileData, long minimum, long maximum) + OrganizationReport report, ReportFile fileData, long minimum, long maximum) { var path = Path.Combine(_baseDirPath, RelativePath(report, fileData.Id!, fileData.FileName)); if (!File.Exists(path)) diff --git a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs index 69726afdb063..18e0a363e01f 100644 --- a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs @@ -8,11 +8,13 @@ public class NoopOrganizationReportStorageService : IOrganizationReportStorageSe { public FileUploadType FileUploadType => FileUploadType.Direct; - public Task GetReportDataUploadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) => Task.FromResult(string.Empty); + public Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult(string.Empty); - public Task GetReportDataDownloadUrlAsync(OrganizationReport report, OrganizationReportFileData fileData) => Task.FromResult(string.Empty); + public Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult(string.Empty); - public Task UploadReportDataAsync(OrganizationReport report, OrganizationReportFileData fileData, Stream stream) => Task.CompletedTask; + public Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream) => Task.CompletedTask; - public Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, OrganizationReportFileData fileData, long minimum, long maximum) => Task.FromResult((true, 0L)); + public Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, ReportFile fileData, long minimum, long maximum) => Task.FromResult((true, 0L)); + + public Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId) => Task.CompletedTask; } diff --git a/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs new file mode 100644 index 000000000000..2de0a6d0c99b --- /dev/null +++ b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs @@ -0,0 +1,35 @@ +using Bit.Api.Dirt.Models.Response; +using Bit.Core.Dirt.Entities; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.Test.Dirt.Models.Response; + +public class OrganizationReportResponseModelTests +{ + [Theory, BitAutoData] + public void Constructor_MapsPropertiesFromEntity(OrganizationReport report) + { + var model = new OrganizationReportResponseModel(report); + + Assert.Equal(report.Id, model.Id); + Assert.Equal(report.OrganizationId, model.OrganizationId); + Assert.Equal(report.ReportData, model.ReportData); + Assert.Equal(report.ContentEncryptionKey, model.ContentEncryptionKey); + Assert.Equal(report.SummaryData, model.SummaryData); + Assert.Equal(report.ApplicationData, model.ApplicationData); + Assert.Equal(report.PasswordCount, model.PasswordCount); + Assert.Equal(report.PasswordAtRiskCount, model.PasswordAtRiskCount); + Assert.Equal(report.MemberCount, model.MemberCount); + Assert.Equal(report.CreationDate, model.CreationDate); + Assert.Equal(report.RevisionDate, model.RevisionDate); + } + + [Theory, BitAutoData] + public void Constructor_FileIsNull(OrganizationReport report) + { + var model = new OrganizationReportResponseModel(report); + + Assert.Null(model.File); + } +} diff --git a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs index 880be1e4d9d1..cf8c233179ba 100644 --- a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs +++ b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs @@ -813,7 +813,7 @@ public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ReturnsOkRe .Returns(expectedReport); // Act - var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); + var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null); // Assert var okResult = Assert.IsType(result); @@ -835,7 +835,7 @@ public async Task UpdateOrganizationReportDataAsync_WithoutAccess_ThrowsNotFound // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null)); // Verify that the command was not called await sutProvider.GetDependency() @@ -860,7 +860,7 @@ public async Task UpdateOrganizationReportDataAsync_WithMismatchedOrgId_ThrowsBa // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null)); Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); @@ -887,7 +887,7 @@ public async Task UpdateOrganizationReportDataAsync_WithMismatchedReportId_Throw // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null)); Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); @@ -918,7 +918,7 @@ public async Task UpdateOrganizationReportDataAsync_CallsCorrectMethods( .Returns(expectedReport); // Act - await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); + await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null); // Assert await sutProvider.GetDependency() diff --git a/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs index 8f04afd1a490..77c120dcde5e 100644 --- a/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs @@ -1,7 +1,6 @@ using AutoFixture; using Bit.Core.AdminConsole.Entities; using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Enums; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Repositories; @@ -41,9 +40,7 @@ public async Task CreateAsync_Success_ReturnsReportWithSerializedFileData( // Assert Assert.NotNull(report); - Assert.Equal(OrganizationReportType.File, report.Type); - - // ReportData should contain serialized OrganizationReportFileData + // ReportData should contain serialized ReportFile Assert.NotEmpty(report.ReportData); var fileData = report.GetReportFileData(); Assert.NotNull(fileData); @@ -59,7 +56,6 @@ await sutProvider.GetDependency() .Received(1) .CreateAsync(Arg.Is(r => r.OrganizationId == request.OrganizationId && - r.Type == OrganizationReportType.File && r.SummaryData == request.SummaryData && r.ApplicationData == request.ApplicationData && r.ContentEncryptionKey == "test-encryption-key")); diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs index db7651b07355..c0d80586511c 100644 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs @@ -1,5 +1,4 @@ using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Enums; using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Dirt.Reports.Services; @@ -17,17 +16,17 @@ public class GetOrganizationReportDataV2QueryTests { private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid organizationId, string fileId) { - var fileData = new OrganizationReportFileData + var fileData = new ReportFile { Id = fileId, + FileName = "report-data.json", Validated = true }; var report = new OrganizationReport { Id = reportId, - OrganizationId = organizationId, - Type = OrganizationReportType.File + OrganizationId = organizationId }; report.SetReportFileData(fileData); return report; @@ -51,7 +50,7 @@ public async Task GetOrganizationReportDataAsync_Success_ReturnsDownloadUrl( .Returns(report); sutProvider.GetDependency() - .GetReportDataDownloadUrlAsync(report, Arg.Any()) + .GetReportDataDownloadUrlAsync(report, Arg.Any()) .Returns(expectedUrl); // Act @@ -63,7 +62,7 @@ public async Task GetOrganizationReportDataAsync_Success_ReturnsDownloadUrl( await sutProvider.GetDependency() .Received(1) - .GetReportDataDownloadUrlAsync(report, Arg.Any()); + .GetReportDataDownloadUrlAsync(report, Arg.Any()); } [Theory] @@ -136,8 +135,7 @@ public async Task GetOrganizationReportDataAsync_EmptyReportData_ThrowsNotFoundE { Id = reportId, OrganizationId = organizationId, - ReportData = string.Empty, - Type = OrganizationReportType.Data + ReportData = string.Empty }; sutProvider.GetDependency() diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs index 1e5dd0576920..0d7bcead329c 100644 --- a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs @@ -1,6 +1,5 @@ using AutoFixture; using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Enums; using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; @@ -18,17 +17,17 @@ public class UpdateOrganizationReportDataV2CommandTests { private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid organizationId, string fileId) { - var fileData = new OrganizationReportFileData + var fileData = new ReportFile { Id = fileId, + FileName = "report-data.json", Validated = false }; var report = new OrganizationReport { Id = reportId, - OrganizationId = organizationId, - Type = OrganizationReportType.File + OrganizationId = organizationId }; report.SetReportFileData(fileData); return report; diff --git a/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs new file mode 100644 index 000000000000..3d9974799d81 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs @@ -0,0 +1,154 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Dirt.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class ValidateOrganizationReportFileCommandTests +{ + private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid organizationId, string fileId) + { + var fileData = new ReportFile + { + Id = fileId, + FileName = "report-data.json", + Validated = false + }; + + var report = new OrganizationReport + { + Id = reportId, + OrganizationId = organizationId, + RevisionDate = DateTime.UtcNow.AddDays(-1) + }; + report.SetReportFileData(fileData); + return report; + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_ValidFile_SetsValidatedAndUpdatesReport( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + var organizationId = Guid.NewGuid(); + var fileId = "test-file-id-123"; + var report = CreateReportWithFileData(reportId, organizationId, fileId); + var originalRevisionDate = report.RevisionDate; + + sutProvider.GetDependency() + .ValidateFileAsync(report, Arg.Any(), 0, Core.Constants.FileSize501mb) + .Returns((true, 12345L)); + + // Act + var result = await sutProvider.Sut.ValidateAsync(report, fileId); + + // Assert + Assert.True(result); + + var fileData = report.GetReportFileData(); + Assert.NotNull(fileData); + Assert.True(fileData!.Validated); + Assert.Equal(12345L, fileData.Size); + Assert.True(report.RevisionDate > originalRevisionDate); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(report); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteReportFilesAsync(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_InvalidFile_DeletesBlobAndReport( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + var organizationId = Guid.NewGuid(); + var fileId = "test-file-id-456"; + var report = CreateReportWithFileData(reportId, organizationId, fileId); + + sutProvider.GetDependency() + .ValidateFileAsync(report, Arg.Any(), 0, Core.Constants.FileSize501mb) + .Returns((false, -1L)); + + // Act + var result = await sutProvider.Sut.ValidateAsync(report, fileId); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency() + .Received(1) + .DeleteReportFilesAsync(report, fileId); + + await sutProvider.GetDependency() + .Received(1) + .DeleteAsync(report); + + await sutProvider.GetDependency() + .DidNotReceive() + .ReplaceAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_NullFileData_ReturnsFalse( + SutProvider sutProvider) + { + // Arrange + var report = new OrganizationReport + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + ReportData = string.Empty + }; + + // Act + var result = await sutProvider.Sut.ValidateAsync(report, "any-file-id"); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency() + .DidNotReceive() + .ValidateFileAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_MismatchedFileId_ReturnsFalse( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + var organizationId = Guid.NewGuid(); + var report = CreateReportWithFileData(reportId, organizationId, "stored-file-id"); + + // Act + var result = await sutProvider.Sut.ValidateAsync(report, "different-file-id"); + + // Assert + Assert.False(result); + + await sutProvider.GetDependency() + .DidNotReceive() + .ValidateFileAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs index a2243f88c41b..f66b9bee02f3 100644 --- a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs @@ -21,11 +21,12 @@ private static AzureOrganizationReportStorageService CreateSut() return new AzureOrganizationReportStorageService(globalSettings, logger); } - private static OrganizationReportFileData CreateFileData(string fileId = "test-file-id-123") + private static ReportFile CreateFileData(string fileId = "test-file-id-123") { - return new OrganizationReportFileData + return new ReportFile { Id = fileId, + FileName = "report-data.json", Validated = false }; } @@ -92,6 +93,18 @@ public async Task GetReportDataDownloadUrlAsync_ReturnsValidSasUrl() Assert.Contains("sp=", url); // Permissions (should be read-only) } + [Theory] + [InlineData("orgId/03-02-2026/reportId/fileId/report-data.json", "reportId")] + [InlineData("abc/01-01-2026/def/ghi/report-data.json", "def")] + public void ReportIdFromBlobName_ExtractsReportId(string blobName, string expectedReportId) + { + // Act + var result = AzureOrganizationReportStorageService.ReportIdFromBlobName(blobName); + + // Assert + Assert.Equal(expectedReportId, result); + } + [Fact] public async Task BlobPath_FormatsCorrectly() { diff --git a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs index 1c7521551d8c..69d8a2e843e4 100644 --- a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs @@ -19,11 +19,12 @@ private static Core.Settings.GlobalSettings GetGlobalSettings() return globalSettings; } - private static OrganizationReportFileData CreateFileData(string fileId = "test-file-id") + private static ReportFile CreateFileData(string fileId = "test-file-id") { - return new OrganizationReportFileData + return new ReportFile { Id = fileId, + FileName = "report-data.json", Validated = false }; } @@ -61,7 +62,7 @@ public async Task GetReportDataUploadUrlAsync_ReturnsApiEndpoint() var url = await sut.GetReportDataUploadUrlAsync(report, fileData); // Assert - Assert.Equal($"/reports/v2/organizations/{orgId}/{reportId}/file/report-data", url); + Assert.Equal($"/reports/organizations/{orgId}/{reportId}/file/report-data", url); } [Fact] @@ -119,9 +120,10 @@ public async Task UploadReportDataAsync_WithPathTraversalPayload_WritesOutsideBa .With(r => r.ReportData, string.Empty) .Create(); - var maliciousFileData = new OrganizationReportFileData + var maliciousFileData = new ReportFile { Id = maliciousFileId, + FileName = "report-data.json", Validated = false }; From afe7c6145eccd19da4b3f9b0380d54efac471ab3 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Wed, 4 Mar 2026 23:36:04 -0600 Subject: [PATCH 04/13] PM-31920 adding request size attributes --- src/Api/Dirt/Controllers/OrganizationReportsController.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index 65e702c64655..2ffd4baaf639 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -136,6 +136,7 @@ public async Task GetOrganizationReportAsync(Guid organizationId, } [HttpPost("{organizationId}")] + [RequestSizeLimit(Constants.FileSize501mb)] public async Task CreateOrganizationReportAsync( Guid organizationId, [FromBody] AddOrganizationReportRequest request) @@ -181,6 +182,7 @@ public async Task CreateOrganizationReportAsync( } [HttpPatch("{organizationId}/{reportId}")] + [RequestSizeLimit(Constants.FileSize501mb)] public async Task UpdateOrganizationReportAsync(Guid organizationId, [FromBody] UpdateOrganizationReportRequest request) { if (!await _currentContext.AccessReports(organizationId)) From f821b5565f70f9a35d4899404ac561da77ffbbb7 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Thu, 5 Mar 2026 11:30:14 -0600 Subject: [PATCH 05/13] PM-31920 fixing all the endpoints --- dev/.claude/settings.local.json | 8 + .../OrganizationReportsController.cs | 494 ++++++------ ...=> OrganizationReportFileResponseModel.cs} | 6 +- .../OrganizationReportResponseModel.cs | 8 +- src/Core/Constants.cs | 2 +- src/Core/Dirt/Entities/OrganizationReport.cs | 12 +- ...ganizationReportDataFileStorageResponse.cs | 6 - ....cs => CreateOrganizationReportCommand.cs} | 11 +- .../GetOrganizationReportDataV2Query.cs | 56 -- ...cs => ICreateOrganizationReportCommand.cs} | 2 +- .../IGetOrganizationReportDataV2Query.cs | 8 - .../IUpdateOrganizationReportDataV2Command.cs | 8 - .../IUpdateOrganizationReportV2Command.cs | 9 + .../ReportingServiceCollectionExtensions.cs | 7 +- .../Requests/AddOrganizationReportRequest.cs | 5 + .../UpdateOrganizationReportV2Request.cs | 13 + .../UpdateOrganizationReportDataV2Command.cs | 50 -- .../UpdateOrganizationReportV2Command.cs | 134 ++++ .../ValidateOrganizationReportFileCommand.cs | 4 +- .../AzureOrganizationReportStorageService.cs | 2 +- .../IOrganizationReportStorageService.cs | 2 +- .../LocalOrganizationReportStorageService.cs | 2 +- .../NoopOrganizationReportStorageService.cs | 2 +- .../OrganizationReportResponseModelTests.cs | 4 +- .../OrganizationReportsControllerTests.cs | 715 ++++++++++++++---- ...> CreateOrganizationReportCommandTests.cs} | 16 +- .../GetOrganizationReportDataV2QueryTests.cs | 149 ---- ...ateOrganizationReportDataV2CommandTests.cs | 95 --- .../UpdateOrganizationReportV2CommandTests.cs | 309 ++++++++ ...idateOrganizationReportFileCommandTests.cs | 4 +- ...reOrganizationReportStorageServiceTests.cs | 6 +- ...alOrganizationReportStorageServiceTests.cs | 4 +- 32 files changed, 1368 insertions(+), 785 deletions(-) create mode 100644 dev/.claude/settings.local.json rename src/Api/Dirt/Models/Response/{OrganizationReportV2ResponseModel.cs => OrganizationReportFileResponseModel.cs} (56%) delete mode 100644 src/Core/Dirt/Models/Data/OrganizationReportDataFileStorageResponse.cs rename src/Core/Dirt/Reports/ReportFeatures/{CreateOrganizationReportV2Command.cs => CreateOrganizationReportCommand.cs} (91%) delete mode 100644 src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs rename src/Core/Dirt/Reports/ReportFeatures/Interfaces/{ICreateOrganizationReportV2Command.cs => ICreateOrganizationReportCommand.cs} (81%) delete mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataV2Query.cs delete mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataV2Command.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportV2Command.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs delete mode 100644 src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs rename test/Core.Test/Dirt/ReportFeatures/{CreateOrganizationReportV2CommandTests.cs => CreateOrganizationReportCommandTests.cs} (90%) delete mode 100644 test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs delete mode 100644 test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs diff --git a/dev/.claude/settings.local.json b/dev/.claude/settings.local.json new file mode 100644 index 000000000000..ae255b535c61 --- /dev/null +++ b/dev/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet test:*)", + "Bash(dotnet build:*)" + ] + } +} diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index 43611547ab3d..067cad0dcc3f 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -3,7 +3,6 @@ using Bit.Api.Utilities; using Bit.Core; using Bit.Core.Context; -using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Reports.Services; @@ -34,10 +33,9 @@ public class OrganizationReportsController : Controller private readonly IFeatureService _featureService; private readonly IApplicationCacheService _applicationCacheService; private readonly IOrganizationReportStorageService _storageService; - private readonly ICreateOrganizationReportV2Command _createV2Command; - private readonly IUpdateOrganizationReportDataV2Command _updateDataV2Command; - private readonly IGetOrganizationReportDataV2Query _getDataV2Query; + private readonly ICreateOrganizationReportCommand _createReportCommand; private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IUpdateOrganizationReportV2Command _updateReportV2Command; private readonly IValidateOrganizationReportFileCommand _validateCommand; private readonly ILogger _logger; @@ -56,10 +54,9 @@ public OrganizationReportsController( IFeatureService featureService, IApplicationCacheService applicationCacheService, IOrganizationReportStorageService storageService, - ICreateOrganizationReportV2Command createV2Command, - IUpdateOrganizationReportDataV2Command updateDataV2Command, - IGetOrganizationReportDataV2Query getDataV2Query, + ICreateOrganizationReportCommand createReportCommand, IOrganizationReportRepository organizationReportRepo, + IUpdateOrganizationReportV2Command updateReportV2Command, IValidateOrganizationReportFileCommand validateCommand, ILogger logger) { @@ -77,43 +74,36 @@ public OrganizationReportsController( _featureService = featureService; _applicationCacheService = applicationCacheService; _storageService = storageService; - _createV2Command = createV2Command; - _updateDataV2Command = updateDataV2Command; - _getDataV2Query = getDataV2Query; + _createReportCommand = createReportCommand; _organizationReportRepo = organizationReportRepo; + _updateReportV2Command = updateReportV2Command; _validateCommand = validateCommand; _logger = logger; } + [HttpGet("{organizationId}/latest")] public async Task GetLatestOrganizationReportAsync(Guid organizationId) { - if (!await _currentContext.AccessReports(organizationId)) + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { - throw new NotFoundException(); - } - - var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); - var response = latestReport == null ? null : new OrganizationReportResponseModel(latestReport); - - return Ok(response); - } + await AuthorizeAsync(organizationId); - [HttpGet("{organizationId}/{reportId}")] - public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) - { - if (_featureService.IsEnabled(FeatureFlagKeys.WholeReportDataFileStorage)) - { - await AuthorizeV2Async(organizationId); + var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); + if (latestReport == null) + { + return Ok(null); + } - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + var response = new OrganizationReportResponseModel(latestReport); - if (report.OrganizationId != organizationId) + var fileData = latestReport.GetReportFile(); + if (fileData is { Validated: true }) { - throw new BadRequestException("Invalid report ID"); + response.ReportFileDownloadUrl = await _storageService.GetReportDataDownloadUrlAsync(latestReport, fileData); } - return Ok(new OrganizationReportResponseModel(report)); + return Ok(response); } if (!await _currentContext.AccessReports(organizationId)) @@ -121,28 +111,24 @@ public async Task GetOrganizationReportAsync(Guid organizationId, throw new NotFoundException(); } - var v1Report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - - if (v1Report == null) - { - throw new NotFoundException("Report not found for the specified organization."); - } - - if (v1Report.OrganizationId != organizationId) - { - throw new BadRequestException("Invalid report ID"); - } + var v1LatestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); + var v1Response = v1LatestReport == null ? null : new OrganizationReportResponseModel(v1LatestReport); - return Ok(v1Report); + return Ok(v1Response); } + /** + * Keeping post v2 launch of Access Intelligence + **/ + + // CREATE Whole Report [HttpPost("{organizationId}")] [RequestSizeLimit(Constants.FileSize501mb)] public async Task CreateOrganizationReportAsync( Guid organizationId, [FromBody] AddOrganizationReportRequest request) { - if (_featureService.IsEnabled(FeatureFlagKeys.WholeReportDataFileStorage)) + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { if (organizationId == Guid.Empty) { @@ -154,14 +140,24 @@ public async Task CreateOrganizationReportAsync( throw new BadRequestException("Organization ID in the request body must match the route parameter"); } - await AuthorizeV2Async(organizationId); + if (!request.FileSize.HasValue) + { + throw new BadRequestException("File size is required."); + } - var report = await _createV2Command.CreateAsync(request); - var fileData = report.GetReportFileData()!; + if (request.FileSize.Value > Constants.FileSize501mb) + { + throw new BadRequestException("Max file size is 500 MB."); + } + + await AuthorizeAsync(organizationId); + + var report = await _createReportCommand.CreateAsync(request); + var fileData = report.GetReportFile()!; - return Ok(new OrganizationReportV2ResponseModel + return Ok(new OrganizationReportFileResponseModel { - ReportDataUploadUrl = await _storageService.GetReportDataUploadUrlAsync(report, fileData), + ReportFileUploadUrl = await _storageService.GetReportFileUploadUrlAsync(report, fileData), ReportResponse = new OrganizationReportResponseModel(report), FileUploadType = _storageService.FileUploadType }); @@ -182,150 +178,81 @@ public async Task CreateOrganizationReportAsync( return Ok(response); } - [HttpPatch("{organizationId}/{reportId}")] - [RequestSizeLimit(Constants.FileSize501mb)] - public async Task UpdateOrganizationReportAsync(Guid organizationId, [FromBody] UpdateOrganizationReportRequest request) - { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } - - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - - var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(request); - var response = new OrganizationReportResponseModel(updatedReport); - return Ok(response); - } - - /// - /// Gets summary data for organization reports within a specified date range. - /// The response is optimized for widget display by returning up to 6 entries that are - /// evenly spaced across the date range, including the most recent entry. - /// This allows the widget to show trends over time while ensuring the latest data point is always included. - /// - /// - /// - /// - /// - /// - /// - [HttpGet("{organizationId}/data/summary")] - [ProducesResponseType>(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetOrganizationReportSummaryDataByDateRangeAsync( - Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) + // READ Whole Report BY IDs + [HttpGet("{organizationId}/{reportId}")] + public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) { - if (!await _currentContext.AccessReports(organizationId)) + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { - throw new NotFoundException(); - } - - if (organizationId == Guid.Empty) - { - throw new BadRequestException("Organization ID is required."); - } + await AuthorizeAsync(organizationId); - var summaryDataList = await _getOrganizationReportSummaryDataByDateRangeQuery - .GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - return Ok(summaryDataList); - } + if (report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } - [HttpGet("{organizationId}/data/summary/{reportId}")] - public async Task GetOrganizationReportSummaryAsync(Guid organizationId, Guid reportId) - { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } + var response = new OrganizationReportResponseModel(report); - var summaryData = - await _getOrganizationReportSummaryDataQuery.GetOrganizationReportSummaryDataAsync(organizationId, reportId); + var fileData = report.GetReportFile(); + if (fileData is { Validated: true }) + { + response.ReportFileDownloadUrl = await _storageService.GetReportDataDownloadUrlAsync(report, fileData); + } - if (summaryData == null) - { - throw new NotFoundException("Report not found for the specified organization."); + return Ok(response); } - return Ok(summaryData); - } - - [HttpPatch("{organizationId}/data/summary/{reportId}")] - public async Task UpdateOrganizationReportSummaryAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportSummaryRequest request) - { if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); } - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - - if (request.ReportId != reportId) - { - throw new BadRequestException("Report ID in the request body must match the route parameter"); - } - var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request); - var response = new OrganizationReportResponseModel(updatedReport); - - return Ok(response); - } + var v1Report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); - [HttpGet("{organizationId}/data/report/{reportId}")] - public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) - { - if (!await _currentContext.AccessReports(organizationId)) + if (v1Report == null) { - throw new NotFoundException(); + throw new NotFoundException("Report not found for the specified organization."); } - var reportData = await _getOrganizationReportDataQuery.GetOrganizationReportDataAsync(organizationId, reportId); - - if (reportData == null) + if (v1Report.OrganizationId != organizationId) { - throw new NotFoundException("Organization report data not found."); + throw new BadRequestException("Invalid report ID"); } - return Ok(reportData); + return Ok(v1Report); } - [HttpPatch("{organizationId}/data/report/{reportId}")] - public async Task UpdateOrganizationReportDataAsync( + // UPDATE Whole Report + [HttpPatch("{organizationId}/{reportId}")] + [RequestSizeLimit(Constants.FileSize501mb)] + public async Task UpdateOrganizationReportAsync( Guid organizationId, Guid reportId, - [FromBody] UpdateOrganizationReportDataRequest request, - [FromQuery] string? reportFileId) + [FromBody] UpdateOrganizationReportV2Request request) { - if (_featureService.IsEnabled(FeatureFlagKeys.WholeReportDataFileStorage)) + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) { - if (request.OrganizationId != organizationId || request.ReportId != reportId) - { - throw new BadRequestException("Organization ID and Report ID must match route parameters"); - } - - if (string.IsNullOrEmpty(reportFileId)) - { - throw new BadRequestException("ReportFileId query parameter is required"); - } + await AuthorizeAsync(organizationId); - await AuthorizeV2Async(organizationId); + request.OrganizationId = organizationId; + request.ReportId = reportId; - var uploadUrl = await _updateDataV2Command.GetUploadUrlAsync(request, reportFileId); - var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + var report = await _updateReportV2Command.UpdateAsync(request); - return Ok(new OrganizationReportV2ResponseModel + if (request.RequiresNewFileUpload) { - ReportDataUploadUrl = uploadUrl, - ReportResponse = new OrganizationReportResponseModel(report), - FileUploadType = _storageService.FileUploadType - }); + var fileData = report.GetReportFile()!; + return Ok(new OrganizationReportFileResponseModel + { + ReportFileUploadUrl = await _storageService.GetReportFileUploadUrlAsync(report, fileData), + ReportResponse = new OrganizationReportResponseModel(report), + FileUploadType = _storageService.FileUploadType + }); + } + + return Ok(new OrganizationReportResponseModel(report)); } if (!await _currentContext.AccessReports(organizationId)) @@ -338,81 +265,59 @@ public async Task UpdateOrganizationReportDataAsync( throw new BadRequestException("Organization ID in the request body must match the route parameter"); } - if (request.ReportId != reportId) + var v1Request = new UpdateOrganizationReportRequest { - throw new BadRequestException("Report ID in the request body must match the route parameter"); - } + ReportId = reportId, + OrganizationId = organizationId, + ReportData = request.ReportData, + ContentEncryptionKey = request.ContentEncryptionKey, + SummaryData = request.SummaryData, + ApplicationData = request.ApplicationData + }; - var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request); + var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(v1Request); var response = new OrganizationReportResponseModel(updatedReport); - return Ok(response); } - [HttpGet("{organizationId}/data/application/{reportId}")] - public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) + /// + /// Gets summary data for organization reports within a specified date range. + /// The response is optimized for widget display by returning up to 6 entries that are + /// evenly spaced across the date range, including the most recent entry. + /// This allows the widget to show trends over time while ensuring the latest data point is always included. + /// + /// + /// + /// + /// + [HttpGet("{organizationId}/data/summary")] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync( + Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) { - try - { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } - - var applicationData = await _getOrganizationReportApplicationDataQuery.GetOrganizationReportApplicationDataAsync(organizationId, reportId); - - if (applicationData == null) - { - throw new NotFoundException("Organization report application data not found."); - } - - return Ok(applicationData); - } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + if (!await _currentContext.AccessReports(organizationId)) { - throw; + throw new NotFoundException(); } - } - [HttpPatch("{organizationId}/data/application/{reportId}")] - public async Task UpdateOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportApplicationDataRequest request) - { - try + if (organizationId == Guid.Empty) { - if (!await _currentContext.AccessReports(organizationId)) - { - throw new NotFoundException(); - } - - if (request.OrganizationId != organizationId) - { - throw new BadRequestException("Organization ID in the request body must match the route parameter"); - } - - if (request.Id != reportId) - { - throw new BadRequestException("Report ID in the request body must match the route parameter"); - } + throw new BadRequestException("Organization ID is required."); + } - var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request); - var response = new OrganizationReportResponseModel(updatedReport); + var summaryDataList = await _getOrganizationReportSummaryDataByDateRangeQuery + .GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); - return Ok(response); - } - catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) - { - throw; - } + return Ok(summaryDataList); } - [RequireFeature(FeatureFlagKeys.WholeReportDataFileStorage)] + [RequireFeature(FeatureFlagKeys.AccessIntelligenceVersion2)] [HttpPost("{organizationId}/{reportId}/file/report-data")] [SelfHosted(SelfHostedOnly = true)] [RequestSizeLimit(Constants.FileSize501mb)] [DisableFormValueModelBinding] - public async Task UploadReportDataAsync(Guid organizationId, Guid reportId, [FromQuery] string reportFileId) + public async Task UploadReportFileAsync(Guid organizationId, Guid reportId, [FromQuery] string reportFileId) { - await AuthorizeV2Async(organizationId); + await AuthorizeAsync(organizationId); if (!Request?.ContentType?.Contains("multipart/") ?? true) { @@ -430,7 +335,7 @@ public async Task UploadReportDataAsync(Guid organizationId, Guid reportId, [Fro throw new BadRequestException("Invalid report ID"); } - var fileData = report.GetReportFileData(); + var fileData = report.GetReportFile(); if (fileData == null || fileData.Id != reportFileId) { throw new NotFoundException(); @@ -441,7 +346,10 @@ await Request.GetFileAsync(async (stream) => await _storageService.UploadReportDataAsync(report, fileData, stream); }); - var (valid, length) = await _storageService.ValidateFileAsync(report, fileData, 0, Constants.FileSize501mb); + var leeway = 1024L * 1024L; // 1 MB + var minimum = Math.Max(0, fileData.Size - leeway); + var maximum = Math.Min(fileData.Size + leeway, Constants.FileSize501mb); + var (valid, length) = await _storageService.ValidateFileAsync(report, fileData, minimum, maximum); if (!valid) { throw new BadRequestException("File received does not match expected constraints."); @@ -449,13 +357,13 @@ await Request.GetFileAsync(async (stream) => fileData.Validated = true; fileData.Size = length; - report.SetReportFileData(fileData); + report.SetReportFile(fileData); report.RevisionDate = DateTime.UtcNow; await _organizationReportRepo.ReplaceAsync(report); } [AllowAnonymous] - [RequireFeature(FeatureFlagKeys.WholeReportDataFileStorage)] + [RequireFeature(FeatureFlagKeys.AccessIntelligenceVersion2)] [HttpPost("file/validate/azure")] public async Task AzureValidateFile() { @@ -480,7 +388,7 @@ public async Task AzureValidateFile() return; } - var fileData = report.GetReportFileData(); + var fileData = report.GetReportFile(); if (fileData == null) { return; @@ -498,7 +406,7 @@ public async Task AzureValidateFile() }); } - private async Task AuthorizeV2Async(Guid organizationId) + private async Task AuthorizeAsync(Guid organizationId) { if (!await _currentContext.AccessReports(organizationId)) { @@ -511,4 +419,148 @@ private async Task AuthorizeV2Async(Guid organizationId) throw new BadRequestException("Your organization's plan does not support this feature."); } } + + // Removing post v2 launch + [HttpPatch("{organizationId}/data/application/{reportId}")] + public async Task UpdateOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportApplicationDataRequest request) + { + try + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.Id != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request); + var response = new OrganizationReportResponseModel(updatedReport); + + return Ok(response); + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + throw; + } + } + + [HttpGet("{organizationId}/data/application/{reportId}")] + public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) + { + try + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var applicationData = await _getOrganizationReportApplicationDataQuery.GetOrganizationReportApplicationDataAsync(organizationId, reportId); + + if (applicationData == null) + { + throw new NotFoundException("Organization report application data not found."); + } + + return Ok(applicationData); + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + throw; + } + } + + [HttpPatch("{organizationId}/data/report/{reportId}")] + public async Task UpdateOrganizationReportDataAsync( + Guid organizationId, + Guid reportId, + [FromBody] UpdateOrganizationReportDataRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.ReportId != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request); + var response = new OrganizationReportResponseModel(updatedReport); + + return Ok(response); + } + + [HttpGet("{organizationId}/data/report/{reportId}")] + public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var reportData = await _getOrganizationReportDataQuery.GetOrganizationReportDataAsync(organizationId, reportId); + + if (reportData == null) + { + throw new NotFoundException("Organization report data not found."); + } + + return Ok(reportData); + } + + [HttpPatch("{organizationId}/data/summary/{reportId}")] + public async Task UpdateOrganizationReportSummaryAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportSummaryRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.ReportId != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request); + var response = new OrganizationReportResponseModel(updatedReport); + + return Ok(response); + } + + [HttpGet("{organizationId}/data/summary/{reportId}")] + public async Task GetOrganizationReportSummaryAsync(Guid organizationId, Guid reportId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var summaryData = + await _getOrganizationReportSummaryDataQuery.GetOrganizationReportSummaryDataAsync(organizationId, reportId); + + if (summaryData == null) + { + throw new NotFoundException("Report not found for the specified organization."); + } + + return Ok(summaryData); + } } diff --git a/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportFileResponseModel.cs similarity index 56% rename from src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs rename to src/Api/Dirt/Models/Response/OrganizationReportFileResponseModel.cs index 63f73d07b4b8..c6ac4607ebfe 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportV2ResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportFileResponseModel.cs @@ -2,11 +2,11 @@ namespace Bit.Api.Dirt.Models.Response; -public class OrganizationReportV2ResponseModel +public class OrganizationReportFileResponseModel { - public OrganizationReportV2ResponseModel() { } + public OrganizationReportFileResponseModel() { } - public string ReportDataUploadUrl { get; set; } = string.Empty; + public string ReportFileUploadUrl { get; set; } = string.Empty; public OrganizationReportResponseModel ReportResponse { get; set; } = null!; public FileUploadType FileUploadType { get; set; } } diff --git a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs index c210303c5b2e..b457476e65a2 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs @@ -14,9 +14,10 @@ public class OrganizationReportResponseModel public int? PasswordCount { get; set; } public int? PasswordAtRiskCount { get; set; } public int? MemberCount { get; set; } - public ReportFile? File { get; set; } - public DateTime? CreationDate { get; set; } = null; - public DateTime? RevisionDate { get; set; } = null; + public ReportFile? ReportFile { get; set; } + public string? ReportFileDownloadUrl { get; set; } + public DateTime? CreationDate { get; set; } + public DateTime? RevisionDate { get; set; } public OrganizationReportResponseModel(OrganizationReport organizationReport) { @@ -34,6 +35,7 @@ public OrganizationReportResponseModel(OrganizationReport organizationReport) PasswordCount = organizationReport.PasswordCount; PasswordAtRiskCount = organizationReport.PasswordAtRiskCount; MemberCount = organizationReport.MemberCount; + ReportFile = organizationReport.GetReportFile(); CreationDate = organizationReport.CreationDate; RevisionDate = organizationReport.RevisionDate; } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8a642be11dbd..7cc92d4a6d34 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -268,7 +268,7 @@ public static class FeatureFlagKeys public const string ArchiveVaultItems = "pm-19148-innovation-archive"; /* DIRT Team */ - public const string WholeReportDataFileStorage = "pm-31920-whole-report-data-file-storage"; + public const string AccessIntelligenceVersion2 = "pm-31920-whole-report-data-file-storage"; public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging"; public const string EventManagementForHuntress = "event-management-for-huntress"; diff --git a/src/Core/Dirt/Entities/OrganizationReport.cs b/src/Core/Dirt/Entities/OrganizationReport.cs index b25c927eff1e..098573dc34f3 100644 --- a/src/Core/Dirt/Entities/OrganizationReport.cs +++ b/src/Core/Dirt/Entities/OrganizationReport.cs @@ -27,21 +27,21 @@ public class OrganizationReport : ITableObject public int? PasswordAtRiskCount { get; set; } public int? CriticalPasswordCount { get; set; } public int? CriticalPasswordAtRiskCount { get; set; } - public ReportFile? OrganizationReportFile { get; set; } + public string? ReportFile { get; set; } - public ReportFile? GetReportFileData() + public ReportFile? GetReportFile() { - if (string.IsNullOrWhiteSpace(ReportData)) + if (string.IsNullOrWhiteSpace(ReportFile)) { return null; } - return JsonSerializer.Deserialize(ReportData); + return JsonSerializer.Deserialize(ReportFile); } - public void SetReportFileData(ReportFile data) + public void SetReportFile(ReportFile data) { - ReportData = JsonSerializer.Serialize(data, JsonHelpers.IgnoreWritingNull); + ReportFile = JsonSerializer.Serialize(data, JsonHelpers.IgnoreWritingNull); } public void SetNewId() diff --git a/src/Core/Dirt/Models/Data/OrganizationReportDataFileStorageResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportDataFileStorageResponse.cs deleted file mode 100644 index 8af6799810e0..000000000000 --- a/src/Core/Dirt/Models/Data/OrganizationReportDataFileStorageResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Bit.Core.Dirt.Models.Data; - -public class OrganizationReportDataFileStorageResponse -{ - public string DownloadUrl { get; set; } = string.Empty; -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportCommand.cs similarity index 91% rename from src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs rename to src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportCommand.cs index 135e4b09dab6..3eeac7518c02 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportV2Command.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/CreateOrganizationReportCommand.cs @@ -10,16 +10,16 @@ namespace Bit.Core.Dirt.Reports.ReportFeatures; -public class CreateOrganizationReportV2Command : ICreateOrganizationReportV2Command +public class CreateOrganizationReportCommand : ICreateOrganizationReportCommand { private readonly IOrganizationRepository _organizationRepo; private readonly IOrganizationReportRepository _organizationReportRepo; - private readonly ILogger _logger; + private readonly ILogger _logger; - public CreateOrganizationReportV2Command( + public CreateOrganizationReportCommand( IOrganizationRepository organizationRepository, IOrganizationReportRepository organizationReportRepository, - ILogger logger) + ILogger logger) { _organizationRepo = organizationRepository; _organizationReportRepo = organizationReportRepository; @@ -44,6 +44,7 @@ public async Task CreateAsync(AddOrganizationReportRequest r { Id = CoreHelpers.SecureRandomString(32, upper: false, special: false), FileName = "report-data.json", + Size = request.FileSize ?? 0, Validated = false }; @@ -68,7 +69,7 @@ public async Task CreateAsync(AddOrganizationReportRequest r CriticalPasswordAtRiskCount = request.ReportMetrics?.CriticalPasswordAtRiskCount, RevisionDate = DateTime.UtcNow }; - organizationReport.SetReportFileData(fileData); + organizationReport.SetReportFile(fileData); var data = await _organizationReportRepo.CreateAsync(organizationReport); diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs deleted file mode 100644 index 2e231d7f073e..000000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataV2Query.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Bit.Core.Dirt.Models.Data; -using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; -using Bit.Core.Dirt.Reports.Services; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Dirt.Reports.ReportFeatures; - -public class GetOrganizationReportDataV2Query : IGetOrganizationReportDataV2Query -{ - private readonly IOrganizationReportRepository _organizationReportRepo; - private readonly IOrganizationReportStorageService _storageService; - private readonly ILogger _logger; - - public GetOrganizationReportDataV2Query( - IOrganizationReportRepository organizationReportRepo, - IOrganizationReportStorageService storageService, - ILogger logger) - { - _organizationReportRepo = organizationReportRepo; - _storageService = storageService; - _logger = logger; - } - - public async Task GetOrganizationReportDataAsync( - Guid organizationId, - Guid reportId, - string reportFileId) - { - _logger.LogInformation(Constants.BypassFiltersEventId, - "Generating download URL for report data - organization {organizationId}, report {reportId}", - organizationId, reportId); - - if (string.IsNullOrEmpty(reportFileId)) - { - throw new BadRequestException("ReportFileId is required"); - } - - var report = await _organizationReportRepo.GetByIdAsync(reportId); - if (report == null || report.OrganizationId != organizationId) - { - throw new NotFoundException("Report not found"); - } - - var fileData = report.GetReportFileData(); - if (fileData == null) - { - throw new NotFoundException("Report file data not found"); - } - - var downloadUrl = await _storageService.GetReportDataDownloadUrlAsync(report, fileData); - - return new OrganizationReportDataFileStorageResponse { DownloadUrl = downloadUrl }; - } -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportCommand.cs similarity index 81% rename from src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportV2Command.cs rename to src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportCommand.cs index 04a2ac5d1812..b090dd12d609 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportV2Command.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/ICreateOrganizationReportCommand.cs @@ -3,7 +3,7 @@ namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; -public interface ICreateOrganizationReportV2Command +public interface ICreateOrganizationReportCommand { Task CreateAsync(AddOrganizationReportRequest request); } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataV2Query.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataV2Query.cs deleted file mode 100644 index e67ec0dec35c..000000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataV2Query.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Core.Dirt.Models.Data; - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; - -public interface IGetOrganizationReportDataV2Query -{ - Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId, string reportFileId); -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataV2Command.cs deleted file mode 100644 index 21d9f005e9dc..000000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataV2Command.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; - -public interface IUpdateOrganizationReportDataV2Command -{ - Task GetUploadUrlAsync(UpdateOrganizationReportDataRequest request, string reportFileId); -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportV2Command.cs new file mode 100644 index 000000000000..a67c7c725d5f --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportV2Command.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportV2Command +{ + Task UpdateAsync(UpdateOrganizationReportV2Request request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index c11575749627..4e1bc0a84beb 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -29,11 +29,8 @@ public static void AddReportingServices(this IServiceCollection services, IGloba services.AddScoped(); // v2 file storage commands - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); - - // v2 file storage queries - services.AddScoped(); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs index f49f9a7fc204..3335ce6cd845 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs @@ -12,4 +12,9 @@ public class AddOrganizationReportRequest public string? ApplicationData { get; set; } public OrganizationReportMetrics? ReportMetrics { get; set; } + + /// + /// Estimated size of the report file in bytes. Required for v2 reports. + /// + public long? FileSize { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs new file mode 100644 index 000000000000..7ec4f76a2ffe --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs @@ -0,0 +1,13 @@ +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportV2Request +{ + public Guid ReportId { get; set; } + public Guid OrganizationId { get; set; } + public string? ReportData { get; set; } + public string? ContentEncryptionKey { get; set; } + public string? SummaryData { get; set; } + public string? ApplicationData { get; set; } + public OrganizationReportMetrics? ReportMetrics { get; set; } + public bool RequiresNewFileUpload { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs deleted file mode 100644 index f4d6bbc85299..000000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataV2Command.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -using Bit.Core.Dirt.Reports.Services; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Dirt.Reports.ReportFeatures; - -public class UpdateOrganizationReportDataV2Command : IUpdateOrganizationReportDataV2Command -{ - private readonly IOrganizationReportRepository _organizationReportRepo; - private readonly IOrganizationReportStorageService _storageService; - private readonly ILogger _logger; - - public UpdateOrganizationReportDataV2Command( - IOrganizationReportRepository organizationReportRepository, - IOrganizationReportStorageService storageService, - ILogger logger) - { - _organizationReportRepo = organizationReportRepository; - _storageService = storageService; - _logger = logger; - } - - public async Task GetUploadUrlAsync(UpdateOrganizationReportDataRequest request, string reportFileId) - { - _logger.LogInformation(Constants.BypassFiltersEventId, - "Generating upload URL for report data - organization {organizationId}, report {reportId}", - request.OrganizationId, request.ReportId); - - var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); - if (existingReport == null || existingReport.OrganizationId != request.OrganizationId) - { - throw new NotFoundException("Report not found"); - } - - var fileData = existingReport.GetReportFileData(); - if (fileData == null || fileData.Id != reportFileId) - { - throw new NotFoundException("Report not found"); - } - - // Update revision date - existingReport.RevisionDate = DateTime.UtcNow; - await _organizationReportRepo.ReplaceAsync(existingReport); - - return await _storageService.GetReportDataUploadUrlAsync(existingReport, fileData); - } -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs new file mode 100644 index 000000000000..fb5ff4a0daac --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs @@ -0,0 +1,134 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportV2Command : IUpdateOrganizationReportV2Command +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportV2Command( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateAsync(UpdateOrganizationReportV2Request request) + { + _logger.LogInformation(Constants.BypassFiltersEventId, + "Updating v2 organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, + "Failed to update v2 organization report {reportId} for organization {organizationId}: {errorMessage}", + request.ReportId, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, + "Organization report {reportId} not found", request.ReportId); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, + "Organization report {reportId} does not belong to organization {organizationId}", + request.ReportId, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + if (request.ContentEncryptionKey != null) + { + existingReport.ContentEncryptionKey = request.ContentEncryptionKey; + } + + if (request.SummaryData != null) + { + existingReport.SummaryData = request.SummaryData; + } + + if (request.ApplicationData != null) + { + existingReport.ApplicationData = request.ApplicationData; + } + + if (request.ReportMetrics != null) + { + existingReport.ApplicationCount = request.ReportMetrics.ApplicationCount; + existingReport.ApplicationAtRiskCount = request.ReportMetrics.ApplicationAtRiskCount; + existingReport.CriticalApplicationCount = request.ReportMetrics.CriticalApplicationCount; + existingReport.CriticalApplicationAtRiskCount = request.ReportMetrics.CriticalApplicationAtRiskCount; + existingReport.MemberCount = request.ReportMetrics.MemberCount; + existingReport.MemberAtRiskCount = request.ReportMetrics.MemberAtRiskCount; + existingReport.CriticalMemberCount = request.ReportMetrics.CriticalMemberCount; + existingReport.CriticalMemberAtRiskCount = request.ReportMetrics.CriticalMemberAtRiskCount; + existingReport.PasswordCount = request.ReportMetrics.PasswordCount; + existingReport.PasswordAtRiskCount = request.ReportMetrics.PasswordAtRiskCount; + existingReport.CriticalPasswordCount = request.ReportMetrics.CriticalPasswordCount; + existingReport.CriticalPasswordAtRiskCount = request.ReportMetrics.CriticalPasswordAtRiskCount; + } + + if (request.RequiresNewFileUpload) + { + var fileData = new ReportFile + { + Id = CoreHelpers.SecureRandomString(32, upper: false, special: false), + FileName = "report-data.json", + Validated = false, + Size = 0 + }; + existingReport.SetReportFile(fileData); + } + + existingReport.RevisionDate = DateTime.UtcNow; + await _organizationReportRepo.ReplaceAsync(existingReport); + + _logger.LogInformation(Constants.BypassFiltersEventId, + "Successfully updated v2 organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + return existingReport; + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync( + UpdateOrganizationReportV2Request request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.ReportId == Guid.Empty) + { + return (false, "ReportId is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs index a78c6880608d..afb4d0f976d0 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ValidateOrganizationReportFileCommand.cs @@ -24,7 +24,7 @@ public ValidateOrganizationReportFileCommand( public async Task ValidateAsync(OrganizationReport report, string reportFileId) { - var fileData = report.GetReportFileData(); + var fileData = report.GetReportFile(); if (fileData == null || fileData.Id != reportFileId) { return false; @@ -43,7 +43,7 @@ public async Task ValidateAsync(OrganizationReport report, string reportFi fileData.Validated = true; fileData.Size = length; - report.SetReportFileData(fileData); + report.SetReportFile(fileData); report.RevisionDate = DateTime.UtcNow; await _organizationReportRepo.ReplaceAsync(report); return true; diff --git a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs index 4580b7c1fbd1..faa8c85c93c8 100644 --- a/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/AzureOrganizationReportStorageService.cs @@ -30,7 +30,7 @@ public AzureOrganizationReportStorageService( _logger = logger; } - public async Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData) + public async Task GetReportFileUploadUrlAsync(OrganizationReport report, ReportFile fileData) { await InitAsync(); var blobClient = _containerClient!.GetBlobClient(BlobPath(report, fileData.Id!, fileData.FileName)); diff --git a/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs index bae2eb793aee..888933f9d400 100644 --- a/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/IOrganizationReportStorageService.cs @@ -8,7 +8,7 @@ public interface IOrganizationReportStorageService { FileUploadType FileUploadType { get; } - Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData); + Task GetReportFileUploadUrlAsync(OrganizationReport report, ReportFile fileData); Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData); diff --git a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs index 98a07d86006d..502a66140754 100644 --- a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs @@ -18,7 +18,7 @@ public LocalOrganizationReportStorageService(GlobalSettings globalSettings) _baseUrl = globalSettings.OrganizationReport.BaseUrl; } - public Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData) + public Task GetReportFileUploadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult($"/reports/organizations/{report.OrganizationId}/{report.Id}/file/report-data"); public Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData) diff --git a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs index 18e0a363e01f..c9260914008d 100644 --- a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs @@ -8,7 +8,7 @@ public class NoopOrganizationReportStorageService : IOrganizationReportStorageSe { public FileUploadType FileUploadType => FileUploadType.Direct; - public Task GetReportDataUploadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult(string.Empty); + public Task GetReportFileUploadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult(string.Empty); public Task GetReportDataDownloadUrlAsync(OrganizationReport report, ReportFile fileData) => Task.FromResult(string.Empty); diff --git a/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs index 2de0a6d0c99b..00062a799d09 100644 --- a/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs +++ b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs @@ -10,6 +10,7 @@ public class OrganizationReportResponseModelTests [Theory, BitAutoData] public void Constructor_MapsPropertiesFromEntity(OrganizationReport report) { + report.ReportFile = null; var model = new OrganizationReportResponseModel(report); Assert.Equal(report.Id, model.Id); @@ -28,8 +29,9 @@ public void Constructor_MapsPropertiesFromEntity(OrganizationReport report) [Theory, BitAutoData] public void Constructor_FileIsNull(OrganizationReport report) { + report.ReportFile = null; var model = new OrganizationReportResponseModel(report); - Assert.Null(model.File); + Assert.Null(model.ReportFile); } } diff --git a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs index cf8c233179ba..f179524f782c 100644 --- a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs +++ b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs @@ -1,11 +1,16 @@ using Bit.Api.Dirt.Controllers; using Bit.Api.Dirt.Models.Response; +using Bit.Core; using Bit.Core.Context; using Bit.Core.Dirt.Entities; using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Services; +using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Mvc; @@ -18,15 +23,21 @@ namespace Bit.Api.Test.Dirt; [SutProviderCustomize] public class OrganizationReportControllerTests { - #region Whole OrganizationReport Endpoints + // GetLatestOrganizationReportAsync - V1 (flag off) [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithValidOrgId_ReturnsOkResult( + public async Task GetLatestOrganizationReportAsync_V1_WithValidOrgId_ReturnsOkResult( SutProvider sutProvider, Guid orgId, OrganizationReport expectedReport) { // Arrange + expectedReport.ReportFile = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -45,31 +56,38 @@ public async Task GetLatestOrganizationReportAsync_WithValidOrgId_ReturnsOkResul } [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task GetLatestOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) - .Returns(Task.FromResult(false)); + .Returns(false); // Act & Assert await Assert.ThrowsAsync(() => sutProvider.Sut.GetLatestOrganizationReportAsync(orgId)); - // Verify that the query was not called await sutProvider.GetDependency() .DidNotReceive() .GetLatestOrganizationReportAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_WhenNoReportFound_ReturnsOkWithNull( + public async Task GetLatestOrganizationReportAsync_V1_WhenNoReportFound_ReturnsOkWithNull( SutProvider sutProvider, Guid orgId) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -86,39 +104,352 @@ public async Task GetLatestOrganizationReportAsync_WhenNoReportFound_ReturnsOkWi Assert.Null(okResult.Value); } + // GetLatestOrganizationReportAsync - V2 (flag on) + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_V2_WithValidatedFile_ReturnsOkWithDownloadUrl( + SutProvider sutProvider, + Guid orgId, + OrganizationReport expectedReport, + string downloadUrl) + { + // Arrange + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + expectedReport.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns(expectedReport); + + sutProvider.GetDependency() + .GetReportDataDownloadUrlAsync(expectedReport, Arg.Any()) + .Returns(downloadUrl); + + // Act + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Equal(downloadUrl, response.ReportFileDownloadUrl); + } + [Theory, BitAutoData] - public async Task GetLatestOrganizationReportAsync_CallsCorrectMethods( + public async Task GetLatestOrganizationReportAsync_V2_WithNoFile_ReturnsOkWithNullDownloadUrl( SutProvider sutProvider, Guid orgId, OrganizationReport expectedReport) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + expectedReport.ReportFile = null; + + SetupV2Authorization(sutProvider, orgId); sutProvider.GetDependency() .GetLatestOrganizationReportAsync(orgId) .Returns(expectedReport); // Act - await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Null(response.ReportFileDownloadUrl); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_V2_NoReport_ReturnsOkWithNull( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns((OrganizationReport)null); + + // Act + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Null(okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetLatestOrganizationReportAsync(orgId)); await sutProvider.GetDependency() - .Received(1) - .GetLatestOrganizationReportAsync(orgId); + .DidNotReceive() + .GetLatestOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_V2_NoUseRiskInsights_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(new OrganizationAbility { UseRiskInsights = false }); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetLatestOrganizationReportAsync(orgId)); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetLatestOrganizationReportAsync(Arg.Any()); + } + + // CreateOrganizationReportAsync - V1 (flag off) + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V1_WithValidRequest_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + expectedReport.ReportFile = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .AddOrganizationReportAsync(request) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + + // Assert + var okResult = Assert.IsType(result); + var expectedResponse = new OrganizationReportResponseModel(expectedReport); + Assert.Equivalent(expectedResponse, okResult.Value); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + await sutProvider.GetDependency() + .DidNotReceive() + .AddOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V1_WithMismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + + await sutProvider.GetDependency() + .DidNotReceive() + .AddOrganizationReportAsync(Arg.Any()); + } + + // CreateOrganizationReportAsync - V2 (flag on) + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_WithValidRequest_ReturnsFileResponseModel( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request, + OrganizationReport expectedReport, + string uploadUrl) + { + // Arrange + request.OrganizationId = orgId; + request.FileSize = 1024; + + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false }; + expectedReport.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .CreateAsync(request) + .Returns(expectedReport); + + sutProvider.GetDependency() + .GetReportFileUploadUrlAsync(expectedReport, Arg.Any()) + .Returns(uploadUrl); + + sutProvider.GetDependency() + .FileUploadType + .Returns(FileUploadType.Azure); + + // Act + var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Equal(uploadUrl, response.ReportFileUploadUrl); + Assert.Equal(FileUploadType.Azure, response.FileUploadType); + Assert.NotNull(response.ReportResponse); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_EmptyOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + AddOrganizationReportRequest request) + { + // Arrange + var emptyOrgId = Guid.Empty; + request.OrganizationId = emptyOrgId; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(emptyOrgId, request)); + + Assert.Equal("Organization ID is required.", exception.Message); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_MismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_MissingFileSize_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = orgId; + request.FileSize = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + Assert.Equal("File size is required.", exception.Message); } + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = orgId; + request.FileSize = 1024; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + await sutProvider.GetDependency() + .DidNotReceive() + .CreateAsync(Arg.Any()); + } + // GetOrganizationReportAsync - V1 (flag off) [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WithValidIds_ReturnsOkResult( + public async Task GetOrganizationReportAsync_V1_WithValidIds_ReturnsOkResult( SutProvider sutProvider, Guid orgId, Guid reportId, @@ -126,6 +457,11 @@ public async Task GetOrganizationReportAsync_WithValidIds_ReturnsOkResult( { // Arrange expectedReport.OrganizationId = orgId; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -143,33 +479,40 @@ public async Task GetOrganizationReportAsync_WithValidIds_ReturnsOkResult( } [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task GetOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, Guid reportId) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) - .Returns(Task.FromResult(false)); + .Returns(false); // Act & Assert await Assert.ThrowsAsync(() => sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - // Verify that the query was not called await sutProvider.GetDependency() .DidNotReceive() .GetOrganizationReportAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WhenReportNotFound_ThrowsNotFoundException( + public async Task GetOrganizationReportAsync_V1_WhenReportNotFound_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, Guid reportId) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -186,14 +529,19 @@ public async Task GetOrganizationReportAsync_WhenReportNotFound_ThrowsNotFoundEx } [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_CallsCorrectMethods( + public async Task GetOrganizationReportAsync_V1_WithOrgMismatch_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, Guid reportId, OrganizationReport expectedReport) { // Arrange - expectedReport.OrganizationId = orgId; + expectedReport.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); @@ -202,205 +550,198 @@ public async Task GetOrganizationReportAsync_CallsCorrectMethods( .GetOrganizationReportAsync(reportId) .Returns(expectedReport); - // Act - await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - await sutProvider.GetDependency() - .Received(1) - .GetOrganizationReportAsync(reportId); + Assert.Equal("Invalid report ID", exception.Message); } + // GetOrganizationReportAsync - V2 (flag on) + [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_WithValidAccess_UsesCorrectReportId( + public async Task GetOrganizationReportAsync_V2_WithValidatedFile_ReturnsOkWithDownloadUrl( SutProvider sutProvider, Guid orgId, Guid reportId, - OrganizationReport expectedReport) + OrganizationReport expectedReport, + string downloadUrl) { // Arrange expectedReport.OrganizationId = orgId; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + expectedReport.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); sutProvider.GetDependency() .GetOrganizationReportAsync(reportId) .Returns(expectedReport); + sutProvider.GetDependency() + .GetReportDataDownloadUrlAsync(expectedReport, Arg.Any()) + .Returns(downloadUrl); + // Act - await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); + var result = await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); // Assert - await sutProvider.GetDependency() - .Received(1) - .GetOrganizationReportAsync(reportId); + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Equal(downloadUrl, response.ReportFileDownloadUrl); } [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_WithValidRequest_ReturnsOkResult( + public async Task GetOrganizationReportAsync_V2_WithNoFile_ReturnsOkWithoutDownloadUrl( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request, + Guid reportId, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; + expectedReport.OrganizationId = orgId; + expectedReport.ReportFile = null; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupV2Authorization(sutProvider, orgId); - sutProvider.GetDependency() - .AddOrganizationReportAsync(request) + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) .Returns(expectedReport); // Act - var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + var result = await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); // Assert var okResult = Assert.IsType(result); - var expectedResponse = new OrganizationReportResponseModel(expectedReport); - Assert.Equivalent(expectedResponse, okResult.Value); + var response = Assert.IsType(okResult.Value); + Assert.Null(response.ReportFileDownloadUrl); } [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task GetOrganizationReportAsync_V2_WithOrgMismatch_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request) + Guid reportId, + OrganizationReport expectedReport) { // Arrange - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(false); - - // Act & Assert - await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + expectedReport.OrganizationId = Guid.NewGuid(); - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .AddOrganizationReportAsync(Arg.Any()); - } + SetupV2Authorization(sutProvider, orgId); - [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadRequestException( - SutProvider sutProvider, - Guid orgId, - AddOrganizationReportRequest request) - { - // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId - - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Returns(expectedReport); // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); - - Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - // Verify that the command was not called - await sutProvider.GetDependency() - .DidNotReceive() - .AddOrganizationReportAsync(Arg.Any()); + Assert.Equal("Invalid report ID", exception.Message); } [Theory, BitAutoData] - public async Task CreateOrganizationReportAsync_CallsCorrectMethods( + public async Task GetOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - AddOrganizationReportRequest request, - OrganizationReport expectedReport) + Guid reportId) { // Arrange - request.OrganizationId = orgId; + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); sutProvider.GetDependency() .AccessReports(orgId) - .Returns(true); - - sutProvider.GetDependency() - .AddOrganizationReportAsync(request) - .Returns(expectedReport); - - // Act - await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + .Returns(false); - // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); - await sutProvider.GetDependency() - .Received(1) - .AddOrganizationReportAsync(request); + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationReportAsync(Arg.Any()); } + // UpdateOrganizationReportAsync - V1 (flag off) + [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_WithValidRequest_ReturnsOkResult( + public async Task UpdateOrganizationReportAsync_V1_WithValidRequest_ReturnsOkResult( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request, + Guid reportId, + UpdateOrganizationReportV2Request request, OrganizationReport expectedReport) { // Arrange request.OrganizationId = orgId; + expectedReport.ReportFile = null; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); sutProvider.GetDependency() .AccessReports(orgId) .Returns(true); sutProvider.GetDependency() - .UpdateOrganizationReportAsync(request) + .UpdateOrganizationReportAsync(Arg.Any()) .Returns(expectedReport); // Act - var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request); + var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request); // Assert var okResult = Assert.IsType(result); var expectedResponse = new OrganizationReportResponseModel(expectedReport); Assert.Equivalent(expectedResponse, okResult.Value); + + await sutProvider.GetDependency() + .Received(1) + .UpdateOrganizationReportAsync(Arg.Is(r => + r.OrganizationId == orgId && r.ReportId == reportId)); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + public async Task UpdateOrganizationReportAsync_V1_WithoutAccess_ThrowsNotFoundException( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request) + Guid reportId, + UpdateOrganizationReportV2Request request) { // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + sutProvider.GetDependency() .AccessReports(orgId) .Returns(false); // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request)); + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request)); - // Verify that the command was not called await sutProvider.GetDependency() .DidNotReceive() .UpdateOrganizationReportAsync(Arg.Any()); } [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadRequestException( + public async Task UpdateOrganizationReportAsync_V1_WithMismatchedOrgId_ThrowsBadRequestException( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request) + Guid reportId, + UpdateOrganizationReportV2Request request) { // Arrange - request.OrganizationId = Guid.NewGuid(); // Different from orgId + request.OrganizationId = Guid.NewGuid(); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); sutProvider.GetDependency() .AccessReports(orgId) @@ -408,50 +749,113 @@ public async Task UpdateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadReq // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request)); + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request)); Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); - // Verify that the command was not called await sutProvider.GetDependency() .DidNotReceive() .UpdateOrganizationReportAsync(Arg.Any()); } + // UpdateOrganizationReportAsync - V2 (flag on) + [Theory, BitAutoData] - public async Task UpdateOrganizationReportAsync_CallsCorrectMethods( + public async Task UpdateOrganizationReportAsync_V2_NoNewFileUpload_ReturnsReportResponseModel( SutProvider sutProvider, Guid orgId, - UpdateOrganizationReportRequest request, + Guid reportId, + UpdateOrganizationReportV2Request request, OrganizationReport expectedReport) { // Arrange - request.OrganizationId = orgId; + request.RequiresNewFileUpload = false; + expectedReport.ReportFile = null; - sutProvider.GetDependency() - .AccessReports(orgId) - .Returns(true); + SetupV2Authorization(sutProvider, orgId); - sutProvider.GetDependency() - .UpdateOrganizationReportAsync(request) + sutProvider.GetDependency() + .UpdateAsync(request) .Returns(expectedReport); // Act - await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request); + var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request); // Assert - await sutProvider.GetDependency() - .Received(1) - .AccessReports(orgId); + var okResult = Assert.IsType(result); + Assert.IsType(okResult.Value); - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) - .UpdateOrganizationReportAsync(request); + .UpdateAsync(request); } - #endregion + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_V2_WithNewFileUpload_ReturnsFileResponseModel( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportV2Request request, + OrganizationReport expectedReport, + string uploadUrl) + { + // Arrange + request.RequiresNewFileUpload = true; + + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false }; + expectedReport.SetReportFile(reportFile); + + SetupV2Authorization(sutProvider, orgId); - #region SummaryData Field Endpoints + sutProvider.GetDependency() + .UpdateAsync(request) + .Returns(expectedReport); + + sutProvider.GetDependency() + .GetReportFileUploadUrlAsync(expectedReport, Arg.Any()) + .Returns(uploadUrl); + + sutProvider.GetDependency() + .FileUploadType + .Returns(FileUploadType.Azure); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request); + + // Assert + var okResult = Assert.IsType(result); + var response = Assert.IsType(okResult.Value); + Assert.Equal(uploadUrl, response.ReportFileUploadUrl); + Assert.Equal(FileUploadType.Azure, response.FileUploadType); + Assert.NotNull(response.ReportResponse); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_V2_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportV2Request request) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, reportId, request)); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateAsync(Arg.Any()); + } + + // SummaryData Field Endpoints [Theory, BitAutoData] public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithValidParameters_ReturnsOkResult( @@ -587,6 +991,7 @@ public async Task UpdateOrganizationReportSummaryAsync_WithValidRequest_ReturnsO // Arrange request.OrganizationId = orgId; request.ReportId = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -692,6 +1097,7 @@ public async Task UpdateOrganizationReportSummaryAsync_CallsCorrectMethods( // Arrange request.OrganizationId = orgId; request.ReportId = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -714,9 +1120,7 @@ await sutProvider.GetDependency() .UpdateOrganizationReportSummaryAsync(request); } - #endregion - - #region ReportData Field Endpoints + // ReportData Field Endpoints [Theory, BitAutoData] public async Task GetOrganizationReportDataAsync_WithValidIds_ReturnsOkResult( @@ -803,6 +1207,7 @@ public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ReturnsOkRe // Arrange request.OrganizationId = orgId; request.ReportId = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -813,7 +1218,7 @@ public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ReturnsOkRe .Returns(expectedReport); // Act - var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null); + var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); // Assert var okResult = Assert.IsType(result); @@ -835,7 +1240,7 @@ public async Task UpdateOrganizationReportDataAsync_WithoutAccess_ThrowsNotFound // Act & Assert await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null)); + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); // Verify that the command was not called await sutProvider.GetDependency() @@ -860,7 +1265,7 @@ public async Task UpdateOrganizationReportDataAsync_WithMismatchedOrgId_ThrowsBa // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null)); + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); @@ -887,7 +1292,7 @@ public async Task UpdateOrganizationReportDataAsync_WithMismatchedReportId_Throw // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null)); + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); @@ -908,6 +1313,7 @@ public async Task UpdateOrganizationReportDataAsync_CallsCorrectMethods( // Arrange request.OrganizationId = orgId; request.ReportId = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -918,7 +1324,7 @@ public async Task UpdateOrganizationReportDataAsync_CallsCorrectMethods( .Returns(expectedReport); // Act - await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request, null); + await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); // Assert await sutProvider.GetDependency() @@ -930,9 +1336,7 @@ await sutProvider.GetDependency() .UpdateOrganizationReportDataAsync(request); } - #endregion - - #region ApplicationData Field Endpoints + // ApplicationData Field Endpoints [Theory, BitAutoData] public async Task GetOrganizationReportApplicationDataAsync_WithValidIds_ReturnsOkResult( @@ -1042,6 +1446,7 @@ public async Task UpdateOrganizationReportApplicationDataAsync_WithValidRequest_ request.OrganizationId = orgId; request.Id = reportId; expectedReport.Id = request.Id; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -1146,6 +1551,7 @@ public async Task UpdateOrganizationReportApplicationDataAsync_CallsCorrectMetho request.OrganizationId = orgId; request.Id = reportId; expectedReport.Id = reportId; + expectedReport.ReportFile = null; sutProvider.GetDependency() .AccessReports(orgId) @@ -1168,5 +1574,22 @@ await sutProvider.GetDependency .UpdateOrganizationReportApplicationDataAsync(request); } - #endregion + // Helper method for setting up V2 authorization mocks + + private static void SetupV2Authorization( + SutProvider sutProvider, + Guid orgId) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(new OrganizationAbility { UseRiskInsights = true }); + } } diff --git a/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportCommandTests.cs similarity index 90% rename from test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs rename to test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportCommandTests.cs index 77c120dcde5e..f49d378519a8 100644 --- a/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportV2CommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/CreateOrganizationReportCommandTests.cs @@ -14,12 +14,12 @@ namespace Bit.Core.Test.Dirt.ReportFeatures; [SutProviderCustomize] -public class CreateOrganizationReportV2CommandTests +public class CreateOrganizationReportCommandTests { [Theory] [BitAutoData] public async Task CreateAsync_Success_ReturnsReportWithSerializedFileData( - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange var fixture = new Fixture(); @@ -40,9 +40,9 @@ public async Task CreateAsync_Success_ReturnsReportWithSerializedFileData( // Assert Assert.NotNull(report); - // ReportData should contain serialized ReportFile - Assert.NotEmpty(report.ReportData); - var fileData = report.GetReportFileData(); + // ReportFile should contain serialized file data + Assert.NotNull(report.ReportFile); + var fileData = report.GetReportFile(); Assert.NotNull(fileData); Assert.NotNull(fileData.Id); Assert.Equal(32, fileData.Id.Length); @@ -64,7 +64,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task CreateAsync_InvalidOrganization_ThrowsBadRequestException( - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange var fixture = new Fixture(); @@ -85,7 +85,7 @@ public async Task CreateAsync_InvalidOrganization_ThrowsBadRequestException( [Theory] [BitAutoData] public async Task CreateAsync_MissingContentEncryptionKey_ThrowsBadRequestException( - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange var fixture = new Fixture(); @@ -106,7 +106,7 @@ public async Task CreateAsync_MissingContentEncryptionKey_ThrowsBadRequestExcept [Theory] [BitAutoData] public async Task CreateAsync_WithMetrics_StoresMetricsCorrectly( - SutProvider sutProvider) + SutProvider sutProvider) { // Arrange var fixture = new Fixture(); diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs deleted file mode 100644 index c0d80586511c..000000000000 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataV2QueryTests.cs +++ /dev/null @@ -1,149 +0,0 @@ -using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Models.Data; -using Bit.Core.Dirt.Reports.ReportFeatures; -using Bit.Core.Dirt.Reports.Services; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Dirt.ReportFeatures; - -[SutProviderCustomize] -public class GetOrganizationReportDataV2QueryTests -{ - private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid organizationId, string fileId) - { - var fileData = new ReportFile - { - Id = fileId, - FileName = "report-data.json", - Validated = true - }; - - var report = new OrganizationReport - { - Id = reportId, - OrganizationId = organizationId - }; - report.SetReportFileData(fileData); - return report; - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_Success_ReturnsDownloadUrl( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var reportId = Guid.NewGuid(); - var reportFileId = "test-file-id-plaintext"; - var expectedUrl = "https://blob.storage.azure.com/sas-url"; - - var report = CreateReportWithFileData(reportId, organizationId, "encrypted-file-id"); - - sutProvider.GetDependency() - .GetByIdAsync(reportId) - .Returns(report); - - sutProvider.GetDependency() - .GetReportDataDownloadUrlAsync(report, Arg.Any()) - .Returns(expectedUrl); - - // Act - var result = await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId); - - // Assert - Assert.NotNull(result); - Assert.Equal(expectedUrl, result.DownloadUrl); - - await sutProvider.GetDependency() - .Received(1) - .GetReportDataDownloadUrlAsync(report, Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_ReportNotFound_ThrowsNotFoundException( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var reportId = Guid.NewGuid(); - var reportFileId = "test-file-id"; - - sutProvider.GetDependency() - .GetByIdAsync(reportId) - .Returns(null as OrganizationReport); - - // Act & Assert - await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId)); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_OrganizationMismatch_ThrowsNotFoundException( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var differentOrgId = Guid.NewGuid(); - var reportId = Guid.NewGuid(); - var reportFileId = "test-file-id"; - - var report = CreateReportWithFileData(reportId, differentOrgId, "file-id"); - - sutProvider.GetDependency() - .GetByIdAsync(reportId) - .Returns(report); - - // Act & Assert - await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId)); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_MissingReportFileId_ThrowsBadRequestException( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var reportId = Guid.NewGuid(); - string? reportFileId = null; - - // Act & Assert - await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId!)); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportDataAsync_EmptyReportData_ThrowsNotFoundException( - SutProvider sutProvider) - { - // Arrange - var organizationId = Guid.NewGuid(); - var reportId = Guid.NewGuid(); - var reportFileId = "test-file-id"; - - var report = new OrganizationReport - { - Id = reportId, - OrganizationId = organizationId, - ReportData = string.Empty - }; - - sutProvider.GetDependency() - .GetByIdAsync(reportId) - .Returns(report); - - // Act & Assert - await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId, reportFileId)); - } -} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs deleted file mode 100644 index 0d7bcead329c..000000000000 --- a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataV2CommandTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using AutoFixture; -using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Models.Data; -using Bit.Core.Dirt.Reports.ReportFeatures; -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Dirt.ReportFeatures; - -[SutProviderCustomize] -public class UpdateOrganizationReportDataV2CommandTests -{ - private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid organizationId, string fileId) - { - var fileData = new ReportFile - { - Id = fileId, - FileName = "report-data.json", - Validated = false - }; - - var report = new OrganizationReport - { - Id = reportId, - OrganizationId = organizationId - }; - report.SetReportFileData(fileData); - return report; - } - - [Theory] - [BitAutoData] - public async Task GetUploadUrlAsync_WithMismatchedFileId_ShouldThrowNotFoundException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - var existingReport = CreateReportWithFileData(request.ReportId, request.OrganizationId, "stored-file-id"); - - sutProvider.GetDependency() - .GetByIdAsync(request.ReportId) - .Returns(existingReport); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.GetUploadUrlAsync(request, "attacker-supplied-file-id")); - - Assert.Equal("Report not found", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task GetUploadUrlAsync_WithNonExistentReport_ShouldThrowNotFoundException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - - sutProvider.GetDependency() - .GetByIdAsync(request.ReportId) - .Returns((OrganizationReport)null); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.GetUploadUrlAsync(request, "any-file-id")); - - Assert.Equal("Report not found", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task GetUploadUrlAsync_WithMismatchedOrgId_ShouldThrowNotFoundException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - var existingReport = CreateReportWithFileData(request.ReportId, Guid.NewGuid(), "file-id"); - - sutProvider.GetDependency() - .GetByIdAsync(request.ReportId) - .Returns(existingReport); - - // Act & Assert - await Assert.ThrowsAsync(async () => - await sutProvider.Sut.GetUploadUrlAsync(request, "any-file-id")); - } -} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs new file mode 100644 index 000000000000..1761c4060d4f --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportV2CommandTests.cs @@ -0,0 +1,309 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class UpdateOrganizationReportV2CommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateAsync_Success_UpdatesFieldsAndReturnsReport( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var existingReport = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, orgId) + .Without(r => r.ReportFile) + .Create(); + + var request = new UpdateOrganizationReportV2Request + { + ReportId = reportId, + OrganizationId = orgId, + ContentEncryptionKey = "new-key", + SummaryData = "new-summary", + ApplicationData = "new-app-data", + RequiresNewFileUpload = false + }; + + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(existingReport); + + var result = await sutProvider.Sut.UpdateAsync(request); + + Assert.NotNull(result); + Assert.Equal("new-key", result.ContentEncryptionKey); + Assert.Equal("new-summary", result.SummaryData); + Assert.Equal("new-app-data", result.ApplicationData); + + await sutProvider.GetDependency() + .Received(1) + .ReplaceAsync(Arg.Is(r => + r.Id == reportId && + r.ContentEncryptionKey == "new-key" && + r.SummaryData == "new-summary" && + r.ApplicationData == "new-app-data")); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_WithMetrics_UpdatesMetricFields( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var existingReport = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, orgId) + .Without(r => r.ReportFile) + .Create(); + + var metrics = new OrganizationReportMetrics + { + ApplicationCount = 100, + ApplicationAtRiskCount = 10, + MemberCount = 50, + MemberAtRiskCount = 5, + PasswordCount = 200, + PasswordAtRiskCount = 20 + }; + + var request = new UpdateOrganizationReportV2Request + { + ReportId = reportId, + OrganizationId = orgId, + ReportMetrics = metrics, + RequiresNewFileUpload = false + }; + + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(existingReport); + + var result = await sutProvider.Sut.UpdateAsync(request); + + Assert.Equal(100, result.ApplicationCount); + Assert.Equal(10, result.ApplicationAtRiskCount); + Assert.Equal(50, result.MemberCount); + Assert.Equal(5, result.MemberAtRiskCount); + Assert.Equal(200, result.PasswordCount); + Assert.Equal(20, result.PasswordAtRiskCount); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_WithRequiresNewFileUpload_CreatesNewReportFile( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var existingReport = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, orgId) + .Without(r => r.ReportFile) + .Create(); + + var request = new UpdateOrganizationReportV2Request + { + ReportId = reportId, + OrganizationId = orgId, + RequiresNewFileUpload = true + }; + + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(existingReport); + + var result = await sutProvider.Sut.UpdateAsync(request); + + var fileData = result.GetReportFile(); + Assert.NotNull(fileData); + Assert.NotNull(fileData.Id); + Assert.Equal(32, fileData.Id.Length); + Assert.Equal("report-data.json", fileData.FileName); + Assert.False(fileData.Validated); + Assert.Equal(0, fileData.Size); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_NullFields_DoesNotOverwriteExisting( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var orgId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + var existingReport = fixture.Build() + .With(r => r.Id, reportId) + .With(r => r.OrganizationId, orgId) + .With(r => r.ContentEncryptionKey, "original-key") + .With(r => r.SummaryData, "original-summary") + .With(r => r.ApplicationData, "original-app-data") + .Without(r => r.ReportFile) + .Create(); + + var request = new UpdateOrganizationReportV2Request + { + ReportId = reportId, + OrganizationId = orgId, + ContentEncryptionKey = null, + SummaryData = null, + ApplicationData = null, + RequiresNewFileUpload = false + }; + + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(reportId) + .Returns(existingReport); + + var result = await sutProvider.Sut.UpdateAsync(request); + + Assert.Equal("original-key", result.ContentEncryptionKey); + Assert.Equal("original-summary", result.SummaryData); + Assert.Equal("original-app-data", result.ApplicationData); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_InvalidOrganization_ThrowsBadRequestException( + SutProvider sutProvider) + { + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.NewGuid(), + OrganizationId = Guid.NewGuid() + }; + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(null as Organization); + + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_ReportNotFound_ThrowsNotFoundException( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.NewGuid(), + OrganizationId = Guid.NewGuid() + }; + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(null as OrganizationReport); + + await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_OrgMismatch_ThrowsBadRequestException( + SutProvider sutProvider) + { + var fixture = new Fixture(); + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.NewGuid(), + OrganizationId = Guid.NewGuid() + }; + + var existingReport = fixture.Build() + .With(r => r.Id, request.ReportId) + .With(r => r.OrganizationId, Guid.NewGuid()) // different org + .Without(r => r.ReportFile) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(fixture.Create()); + + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + Assert.Equal("Organization report does not belong to the specified organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_EmptyOrganizationId_ThrowsBadRequestException( + SutProvider sutProvider) + { + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.NewGuid(), + OrganizationId = Guid.Empty + }; + + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + Assert.Equal("OrganizationId is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_EmptyReportId_ThrowsBadRequestException( + SutProvider sutProvider) + { + var request = new UpdateOrganizationReportV2Request + { + ReportId = Guid.Empty, + OrganizationId = Guid.NewGuid() + }; + + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.UpdateAsync(request)); + Assert.Equal("ReportId is required", exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs index 3d9974799d81..68691e5ef120 100644 --- a/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/ValidateOrganizationReportFileCommandTests.cs @@ -28,7 +28,7 @@ private static OrganizationReport CreateReportWithFileData(Guid reportId, Guid o OrganizationId = organizationId, RevisionDate = DateTime.UtcNow.AddDays(-1) }; - report.SetReportFileData(fileData); + report.SetReportFile(fileData); return report; } @@ -54,7 +54,7 @@ public async Task ValidateAsync_ValidFile_SetsValidatedAndUpdatesReport( // Assert Assert.True(result); - var fileData = report.GetReportFileData(); + var fileData = report.GetReportFile(); Assert.NotNull(fileData); Assert.True(fileData!.Validated); Assert.Equal(12345L, fileData.Size); diff --git a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs index f66b9bee02f3..c8f5e4993343 100644 --- a/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/AzureOrganizationReportStorageServiceTests.cs @@ -39,7 +39,7 @@ public void FileUploadType_ReturnsAzure() } [Fact] - public async Task GetReportDataUploadUrlAsync_ReturnsValidSasUrl() + public async Task GetReportFileUploadUrlAsync_ReturnsValidSasUrl() { // Arrange var fixture = new Fixture(); @@ -55,7 +55,7 @@ public async Task GetReportDataUploadUrlAsync_ReturnsValidSasUrl() var fileData = CreateFileData(); // Act - var url = await sut.GetReportDataUploadUrlAsync(report, fileData); + var url = await sut.GetReportFileUploadUrlAsync(report, fileData); // Assert Assert.NotNull(url); @@ -125,7 +125,7 @@ public async Task BlobPath_FormatsCorrectly() .Create(); // Act - var url = await sut.GetReportDataUploadUrlAsync(report, fileData); + var url = await sut.GetReportFileUploadUrlAsync(report, fileData); // Assert // Expected path: {orgId}/{MM-dd-yyyy}/{reportId}/{fileId}/report-data.json diff --git a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs index 69d8a2e843e4..2fec83ab927c 100644 --- a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs @@ -41,7 +41,7 @@ public void FileUploadType_ReturnsDirect() } [Fact] - public async Task GetReportDataUploadUrlAsync_ReturnsApiEndpoint() + public async Task GetReportFileUploadUrlAsync_ReturnsApiEndpoint() { // Arrange var fixture = new Fixture(); @@ -59,7 +59,7 @@ public async Task GetReportDataUploadUrlAsync_ReturnsApiEndpoint() var fileData = CreateFileData(); // Act - var url = await sut.GetReportDataUploadUrlAsync(report, fileData); + var url = await sut.GetReportFileUploadUrlAsync(report, fileData); // Assert Assert.Equal($"/reports/organizations/{orgId}/{reportId}/file/report-data", url); From a2f6db2fcb5450bb16c8ba063a85befa337ba18f Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Thu, 5 Mar 2026 11:33:15 -0600 Subject: [PATCH 06/13] PM-31920 remove claude change --- dev/.claude/settings.local.json | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 dev/.claude/settings.local.json diff --git a/dev/.claude/settings.local.json b/dev/.claude/settings.local.json deleted file mode 100644 index ae255b535c61..000000000000 --- a/dev/.claude/settings.local.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(dotnet test:*)", - "Bash(dotnet build:*)" - ] - } -} From f0e0b51c09a6d11763526b2c8c1d94a461b1fd61 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Thu, 5 Mar 2026 11:37:33 -0600 Subject: [PATCH 07/13] PM-31920 fixing feature flag name --- src/Core/Constants.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 7cc92d4a6d34..7cbe4a2254c0 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -268,7 +268,7 @@ public static class FeatureFlagKeys public const string ArchiveVaultItems = "pm-19148-innovation-archive"; /* DIRT Team */ - public const string AccessIntelligenceVersion2 = "pm-31920-whole-report-data-file-storage"; + public const string AccessIntelligenceVersion2 = "pm-31920-access-intelligence-azure-file-storage"; public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging"; public const string EventManagementForHuntress = "event-management-for-huntress"; From 5326782496be31d5d146775d5524aa1c847203e5 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Fri, 6 Mar 2026 07:35:31 -0600 Subject: [PATCH 08/13] PM-31920 fixing path traversal vuln and cleaned up null references --- .../Controllers/OrganizationReportsController.cs | 12 +++++++++++- .../LocalOrganizationReportStorageService.cs | 12 ++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index 067cad0dcc3f..d7b55ac96c36 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -188,6 +188,11 @@ public async Task GetOrganizationReportAsync(Guid organizationId, var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + if (report == null) + { + throw new NotFoundException("Report not found for the specified organization."); + } + if (report.OrganizationId != organizationId) { throw new BadRequestException("Invalid report ID"); @@ -321,7 +326,7 @@ public async Task UploadReportFileAsync(Guid organizationId, Guid reportId, [Fro if (!Request?.ContentType?.Contains("multipart/") ?? true) { - throw new BadRequestException("Invalid contenwt."); + throw new BadRequestException("Invalid content."); } if (string.IsNullOrEmpty(reportFileId)) @@ -330,6 +335,11 @@ public async Task UploadReportFileAsync(Guid organizationId, Guid reportId, [Fro } var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + if (report == null) + { + throw new NotFoundException(); + } + if (report.OrganizationId != organizationId) { throw new BadRequestException("Invalid report ID"); diff --git a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs index 502a66140754..6148278e323a 100644 --- a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs @@ -34,6 +34,7 @@ public async Task UploadReportDataAsync(OrganizationReport report, ReportFile fi OrganizationReport report, ReportFile fileData, long minimum, long maximum) { var path = Path.Combine(_baseDirPath, RelativePath(report, fileData.Id!, fileData.FileName)); + EnsurePathWithinBaseDir(path); if (!File.Exists(path)) { return Task.FromResult((false, -1L)); @@ -59,6 +60,7 @@ private async Task WriteFileAsync(OrganizationReport report, string fileId, stri { InitDir(); var path = Path.Combine(_baseDirPath, RelativePath(report, fileId, fileName)); + EnsurePathWithinBaseDir(path); Directory.CreateDirectory(Path.GetDirectoryName(path)!); using var fs = File.Create(path); stream.Seek(0, SeekOrigin.Begin); @@ -72,6 +74,16 @@ private static string RelativePath(OrganizationReport report, string fileId, str fileId, fileName); } + private void EnsurePathWithinBaseDir(string path) + { + var fullPath = Path.GetFullPath(path); + var fullBaseDir = Path.GetFullPath(_baseDirPath + Path.DirectorySeparatorChar); + if (!fullPath.StartsWith(fullBaseDir, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Path traversal detected."); + } + } + private void InitDir() { if (!Directory.Exists(_baseDirPath)) From 0e48567f41f0e38279ffe8d7f5e2c833d5117d43 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Fri, 6 Mar 2026 09:47:17 -0600 Subject: [PATCH 09/13] PM-31920 fixing unit test --- .../GetOrganizationReportApplicationDataQuery.cs | 16 ++++++++++++++++ ...rganizationReportApplicationDataQueryTests.cs | 8 ++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs index e1eeba0982c2..f2947b847d9a 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs @@ -1,6 +1,7 @@ using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; using Microsoft.Extensions.Logging; namespace Bit.Core.Dirt.Reports.ReportFeatures; @@ -20,8 +21,23 @@ public GetOrganizationReportApplicationDataQuery( public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) { + if (organizationId == Guid.Empty) + { + throw new BadRequestException("OrganizationId is required."); + } + + if (reportId == Guid.Empty) + { + throw new BadRequestException("ReportId is required."); + } + var applicationDataResponse = await _organizationReportRepo.GetApplicationDataAsync(reportId); + if (applicationDataResponse == null) + { + throw new NotFoundException("Organization report application data not found."); + } + return applicationDataResponse; } } diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs index c9281d52d130..8d399f07c353 100644 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs @@ -42,11 +42,9 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task GetOrganizationReportApplicationDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + Guid reportId, SutProvider sutProvider) { - // Arrange - var reportId = Guid.NewGuid(); - // Act & Assert var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(Guid.Empty, reportId)); @@ -59,11 +57,9 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task GetOrganizationReportApplicationDataAsync_WithEmptyReportId_ShouldThrowBadRequestException( + Guid organizationId, SutProvider sutProvider) { - // Arrange - var organizationId = Guid.NewGuid(); - // Act & Assert var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(organizationId, Guid.Empty)); From ad38c6738abd468563cfcd15d7bb2520b17a2703 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Fri, 6 Mar 2026 09:56:34 -0600 Subject: [PATCH 10/13] PM-31920 fixing issues found by reviewer --- .../LocalOrganizationReportStorageService.cs | 1 + ...alOrganizationReportStorageServiceTests.cs | 19 +++++-------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs index 6148278e323a..27a44a3070e6 100644 --- a/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/LocalOrganizationReportStorageService.cs @@ -49,6 +49,7 @@ public Task DeleteReportFilesAsync(OrganizationReport report, string reportFileI { var dirPath = Path.Combine(_baseDirPath, report.OrganizationId.ToString(), report.CreationDate.ToString("MM-dd-yyyy"), report.Id.ToString(), reportFileId); + EnsurePathWithinBaseDir(dirPath); if (Directory.Exists(dirPath)) { Directory.Delete(dirPath, true); diff --git a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs index 2fec83ab927c..8609ea65854f 100644 --- a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs @@ -100,10 +100,9 @@ public async Task GetReportDataDownloadUrlAsync_ReturnsBaseUrlWithPath() [Theory] [InlineData("../../etc/malicious")] [InlineData("../../../tmp/evil")] - public async Task UploadReportDataAsync_WithPathTraversalPayload_WritesOutsideBaseDirectory(string maliciousFileId) + public async Task UploadReportDataAsync_WithPathTraversalPayload_ThrowsInvalidOperationException(string maliciousFileId) { - // Arrange - demonstrates the path traversal vulnerability that is mitigated - // by validating reportFileId matches report's file data at the controller/command layer + // Arrange var fixture = new Fixture(); var tempDir = Path.Combine(Path.GetTempPath(), "bitwarden-test-" + Guid.NewGuid()); @@ -132,17 +131,9 @@ public async Task UploadReportDataAsync_WithPathTraversalPayload_WritesOutsideBa try { - // Act - await sut.UploadReportDataAsync(report, maliciousFileData, stream); - - // Assert - the file is written at a path that escapes the intended report directory - var intendedBaseDir = Path.Combine(tempDir, report.OrganizationId.ToString(), - report.CreationDate.ToString("MM-dd-yyyy"), report.Id.ToString()); - var actualFilePath = Path.Combine(intendedBaseDir, maliciousFileId, "report-data.json"); - var resolvedPath = Path.GetFullPath(actualFilePath); - - // This demonstrates the vulnerability: the resolved path escapes the base directory - Assert.False(resolvedPath.StartsWith(Path.GetFullPath(intendedBaseDir))); + // Act & Assert - EnsurePathWithinBaseDir guard rejects the traversal attempt + await Assert.ThrowsAsync( + () => sut.UploadReportDataAsync(report, maliciousFileData, stream)); } finally { From 1c4e37f753c2a65505bfdecd78c7d36c0b94b1f5 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Fri, 6 Mar 2026 11:08:56 -0600 Subject: [PATCH 11/13] PM-31920 addressing pr comments --- .../Controllers/OrganizationReportsController.cs | 13 +++++++++++++ .../Response/OrganizationReportResponseModel.cs | 6 ------ src/Core/Dirt/Models/Data/ReportFile.cs | 2 +- .../Requests/UpdateOrganizationReportV2Request.cs | 5 +++++ .../UpdateOrganizationReportV2Command.cs | 12 +++++++++++- .../NoopOrganizationReportStorageService.cs | 2 +- .../OrganizationReportResponseModelTests.cs | 3 --- test/Core.Test/Dirt/Models/Data/ReportFileTests.cs | 2 +- .../LocalOrganizationReportStorageServiceTests.cs | 4 ++-- 9 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index d7b55ac96c36..83b97a3b46a4 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -244,6 +244,19 @@ public async Task UpdateOrganizationReportAsync( request.OrganizationId = organizationId; request.ReportId = reportId; + if (request.RequiresNewFileUpload) + { + if (!request.FileSize.HasValue) + { + throw new BadRequestException("File size is required."); + } + + if (request.FileSize.Value > Constants.FileSize501mb) + { + throw new BadRequestException("Max file size is 500 MB."); + } + } + var report = await _updateReportV2Command.UpdateAsync(request); if (request.RequiresNewFileUpload) diff --git a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs index b457476e65a2..f0f3a90c1102 100644 --- a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs +++ b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs @@ -11,9 +11,6 @@ public class OrganizationReportResponseModel public string? ContentEncryptionKey { get; set; } public string? SummaryData { get; set; } public string? ApplicationData { get; set; } - public int? PasswordCount { get; set; } - public int? PasswordAtRiskCount { get; set; } - public int? MemberCount { get; set; } public ReportFile? ReportFile { get; set; } public string? ReportFileDownloadUrl { get; set; } public DateTime? CreationDate { get; set; } @@ -32,9 +29,6 @@ public OrganizationReportResponseModel(OrganizationReport organizationReport) ContentEncryptionKey = organizationReport.ContentEncryptionKey; SummaryData = organizationReport.SummaryData; ApplicationData = organizationReport.ApplicationData; - PasswordCount = organizationReport.PasswordCount; - PasswordAtRiskCount = organizationReport.PasswordAtRiskCount; - MemberCount = organizationReport.MemberCount; ReportFile = organizationReport.GetReportFile(); CreationDate = organizationReport.CreationDate; RevisionDate = organizationReport.RevisionDate; diff --git a/src/Core/Dirt/Models/Data/ReportFile.cs b/src/Core/Dirt/Models/Data/ReportFile.cs index af05c56918dc..fa0cb11166e9 100644 --- a/src/Core/Dirt/Models/Data/ReportFile.cs +++ b/src/Core/Dirt/Models/Data/ReportFile.cs @@ -26,5 +26,5 @@ public class ReportFile /// /// When true the uploaded file's length has been validated. /// - public bool Validated { get; set; } = true; + public bool Validated { get; set; } = false; } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs index 7ec4f76a2ffe..5a44f4684e05 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportV2Request.cs @@ -10,4 +10,9 @@ public class UpdateOrganizationReportV2Request public string? ApplicationData { get; set; } public OrganizationReportMetrics? ReportMetrics { get; set; } public bool RequiresNewFileUpload { get; set; } + + /// + /// Estimated size of the report file in bytes. Required when RequiresNewFileUpload is true. + /// + public long? FileSize { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs index fb5ff4a0daac..468b0a6f7ec6 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportV2Command.cs @@ -2,6 +2,7 @@ using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Reports.Services; using Bit.Core.Dirt.Repositories; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -14,15 +15,18 @@ public class UpdateOrganizationReportV2Command : IUpdateOrganizationReportV2Comm { private readonly IOrganizationRepository _organizationRepo; private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly IOrganizationReportStorageService _storageService; private readonly ILogger _logger; public UpdateOrganizationReportV2Command( IOrganizationRepository organizationRepository, IOrganizationReportRepository organizationReportRepository, + IOrganizationReportStorageService storageService, ILogger logger) { _organizationRepo = organizationRepository; _organizationReportRepo = organizationReportRepository; + _storageService = storageService; _logger = logger; } @@ -90,12 +94,18 @@ public async Task UpdateAsync(UpdateOrganizationReportV2Requ if (request.RequiresNewFileUpload) { + var oldFileData = existingReport.GetReportFile(); + if (oldFileData?.Id != null) + { + await _storageService.DeleteReportFilesAsync(existingReport, oldFileData.Id); + } + var fileData = new ReportFile { Id = CoreHelpers.SecureRandomString(32, upper: false, special: false), FileName = "report-data.json", Validated = false, - Size = 0 + Size = request.FileSize ?? 0 }; existingReport.SetReportFile(fileData); } diff --git a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs index c9260914008d..fb56ced538cf 100644 --- a/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs +++ b/src/Core/Dirt/Reports/Services/NoopOrganizationReportStorageService.cs @@ -14,7 +14,7 @@ public class NoopOrganizationReportStorageService : IOrganizationReportStorageSe public Task UploadReportDataAsync(OrganizationReport report, ReportFile fileData, Stream stream) => Task.CompletedTask; - public Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, ReportFile fileData, long minimum, long maximum) => Task.FromResult((true, 0L)); + public Task<(bool valid, long length)> ValidateFileAsync(OrganizationReport report, ReportFile fileData, long minimum, long maximum) => Task.FromResult((true, fileData.Size)); public Task DeleteReportFilesAsync(OrganizationReport report, string reportFileId) => Task.CompletedTask; } diff --git a/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs index 00062a799d09..2d67407a050d 100644 --- a/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs +++ b/test/Api.Test/Dirt/Models/Response/OrganizationReportResponseModelTests.cs @@ -19,9 +19,6 @@ public void Constructor_MapsPropertiesFromEntity(OrganizationReport report) Assert.Equal(report.ContentEncryptionKey, model.ContentEncryptionKey); Assert.Equal(report.SummaryData, model.SummaryData); Assert.Equal(report.ApplicationData, model.ApplicationData); - Assert.Equal(report.PasswordCount, model.PasswordCount); - Assert.Equal(report.PasswordAtRiskCount, model.PasswordAtRiskCount); - Assert.Equal(report.MemberCount, model.MemberCount); Assert.Equal(report.CreationDate, model.CreationDate); Assert.Equal(report.RevisionDate, model.RevisionDate); } diff --git a/test/Core.Test/Dirt/Models/Data/ReportFileTests.cs b/test/Core.Test/Dirt/Models/Data/ReportFileTests.cs index db0a1865df88..eeb71955584c 100644 --- a/test/Core.Test/Dirt/Models/Data/ReportFileTests.cs +++ b/test/Core.Test/Dirt/Models/Data/ReportFileTests.cs @@ -14,7 +14,7 @@ public void DefaultValues_AreCorrect() Assert.Null(data.Id); Assert.Equal(string.Empty, data.FileName); Assert.Equal(0, data.Size); - Assert.True(data.Validated); + Assert.False(data.Validated); } [Fact] diff --git a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs index 8609ea65854f..81a36e4055bf 100644 --- a/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs +++ b/test/Core.Test/Dirt/Reports/Services/LocalOrganizationReportStorageServiceTests.cs @@ -98,8 +98,8 @@ public async Task GetReportDataDownloadUrlAsync_ReturnsBaseUrlWithPath() } [Theory] - [InlineData("../../etc/malicious")] - [InlineData("../../../tmp/evil")] + [InlineData("../../../../etc/malicious")] + [InlineData("../../../../../tmp/evil")] public async Task UploadReportDataAsync_WithPathTraversalPayload_ThrowsInvalidOperationException(string maliciousFileId) { // Arrange From 4bb40d0718436f773f4f72f73e910c042e4e5d5b Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Fri, 6 Mar 2026 16:13:51 -0600 Subject: [PATCH 12/13] PM-31920 fixing issues based on review --- dev/.claude/settings.local.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 dev/.claude/settings.local.json diff --git a/dev/.claude/settings.local.json b/dev/.claude/settings.local.json new file mode 100644 index 000000000000..d56caaa428fd --- /dev/null +++ b/dev/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(gh api:*)" + ] + } +} From adaf501375660cbcca4e52fe290b6d13de732b33 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Fri, 6 Mar 2026 16:17:45 -0600 Subject: [PATCH 13/13] PM-31920 removing settings.json --- dev/.claude/settings.local.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 dev/.claude/settings.local.json diff --git a/dev/.claude/settings.local.json b/dev/.claude/settings.local.json deleted file mode 100644 index d56caaa428fd..000000000000 --- a/dev/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(gh api:*)" - ] - } -}