diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index e13b4c32d20c..41794eb6df31 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -141,19 +141,18 @@ public async Task GetLatestOrganizationReportAsync(Guid organizat await AuthorizeAsync(organizationId); - var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); + var isAccessIntelligenceV2 = _featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2); - if (latestReport == null) - { - throw new NotFoundException(); - } + var latestReport = isAccessIntelligenceV2 + ? await _getOrganizationReportQuery.ReadLatestOrganizationReportAsync(organizationId) + : await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); var response = new OrganizationReportResponseModel(latestReport); - if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2)) + if (isAccessIntelligenceV2) { var fileData = latestReport.GetReportFile(); - if (fileData is { Validated: true }) + if (fileData != null) { response.ReportFileDownloadUrl = await _storageService.GetReportDataDownloadUrlAsync(latestReport, fileData); response.FileUploadType = _storageService.FileUploadType; @@ -393,7 +392,7 @@ public async Task UploadReportFileAsync(Guid organizationId, Guid reportId, [Fro } var fileData = report.GetReportFile(); - if (fileData == null || fileData.Id != reportFileId) + if (fileData == null || fileData.Id != reportFileId || fileData.Validated) { throw new NotFoundException(); } @@ -449,6 +448,11 @@ public async Task DownloadReportFileAsync(Guid organizationId, Gu throw new NotFoundException(); } + if (_featureService.IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) && !fileData.Validated) + { + throw new NotFoundException(); + } + var stream = await _storageService.GetReportReadStreamAsync(report, fileData); if (stream == null) { diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs index 7928b2956f87..f4563db3bc15 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs @@ -32,6 +32,21 @@ public async Task GetLatestOrganizationReportAsync(Guid orga return result; } + public async Task ReadLatestOrganizationReportAsync(Guid organizationId) + { + _logger.LogInformation(Constants.BypassFiltersEventId, + "Reading latest validated-file organization report for organization {organizationId}", + organizationId); + var result = await _organizationReportRepo.ReadLatestByOrganizationIdAsync(organizationId); + + if (result == null) + { + throw new NotFoundException($"No validated report found for organization: {organizationId}"); + } + + return result; + } + public async Task GetOrganizationReportAsync(Guid reportId) { _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization reports for organization by Id: {reportId}", reportId); diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs index b72fdd25b565..eb7ce6735105 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs @@ -6,4 +6,5 @@ public interface IGetOrganizationReportQuery { Task GetOrganizationReportAsync(Guid organizationId); Task GetLatestOrganizationReportAsync(Guid organizationId); + Task ReadLatestOrganizationReportAsync(Guid organizationId); } diff --git a/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs index fb85361a63ff..85baa07ed36f 100644 --- a/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs +++ b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs @@ -9,6 +9,7 @@ public interface IOrganizationReportRepository : IRepository GetLatestByOrganizationIdAsync(Guid organizationId); + Task ReadLatestByOrganizationIdAsync(Guid organizationId); // SummaryData methods Task> GetSummaryDataByDateRangeAsync(Guid organizationId, DateTime startDate, DateTime endDate); diff --git a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs index 2a6ee83985f7..066d0ee71db3 100644 --- a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs +++ b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs @@ -38,6 +38,19 @@ public async Task GetLatestByOrganizationIdAsync(Guid organi } } + public async Task ReadLatestByOrganizationIdAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var result = await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_ReadLatestByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + public async Task UpdateSummaryDataAsync(Guid organizationId, Guid reportId, string summaryData) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs index 6691079179ec..248ee2515c8c 100644 --- a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs +++ b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs @@ -27,7 +27,31 @@ public async Task GetLatestByOrganizationIdAsync(Guid organi { var dbContext = GetDatabaseContext(scope); var result = await dbContext.OrganizationReports - .Where(p => p.OrganizationId == organizationId) + .Where(p => p.OrganizationId == organizationId + && p.ReportData != string.Empty) + .OrderByDescending(p => p.RevisionDate) + .Take(1) + .FirstOrDefaultAsync(); + + if (result == null) return default; + + return Mapper.Map(result); + } + } + + public async Task ReadLatestByOrganizationIdAsync(Guid organizationId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + // Substring match is coupled to SetReportFile (OrganizationReport.cs) writing via + // JsonHelpers.IgnoreWritingNull (PascalCase, no whitespace). The Dapper/MSSQL path + // uses JSON_VALUE which is format-agnostic; changing the serializer options would + // silently return zero rows here without affecting the MSSQL path. + var result = await dbContext.OrganizationReports + .Where(p => p.OrganizationId == organizationId + && p.ReportFile != null + && p.ReportFile.Contains("\"Validated\":true")) .OrderByDescending(p => p.RevisionDate) .Take(1) .FirstOrDefaultAsync(); diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql index fca8788ce6f7..41bae326a25d 100644 --- a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql @@ -6,7 +6,11 @@ BEGIN SELECT TOP 1 * - FROM [dbo].[OrganizationReportView] - WHERE [OrganizationId] = @OrganizationId - ORDER BY [RevisionDate] DESC + FROM + [dbo].[OrganizationReportView] + WHERE + [OrganizationId] = @OrganizationId + AND [ReportData] <> '' + ORDER BY + [RevisionDate] DESC END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadLatestByOrganizationId.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadLatestByOrganizationId.sql new file mode 100644 index 000000000000..0c2ad783143b --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadLatestByOrganizationId.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_ReadLatestByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT TOP 1 + * + FROM + [dbo].[OrganizationReportView] + WHERE + [OrganizationId] = @OrganizationId + AND JSON_VALUE([ReportFile], '$.Validated') = 'true' + ORDER BY + [RevisionDate] DESC +END diff --git a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs index 0b6c5b828fe3..1b0249355898 100644 --- a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs +++ b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs @@ -15,6 +15,7 @@ using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using NSubstitute; @@ -48,7 +49,7 @@ public async Task GetLatestOrganizationReportAsync_WithValidatedFile_ReturnsOkWi .Returns(true); sutProvider.GetDependency() - .GetLatestOrganizationReportAsync(orgId) + .ReadLatestOrganizationReportAsync(orgId) .Returns(expectedReport); sutProvider.GetDependency() @@ -69,6 +70,77 @@ public async Task GetLatestOrganizationReportAsync_WithValidatedFile_ReturnsOkWi Assert.Equal(FileUploadType.Azure, response.FileUploadType); } + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_FlagOn_CallsReadLatest( + SutProvider sutProvider, + Guid orgId, + OrganizationReport expectedReport) + { + // Arrange + expectedReport.OrganizationId = orgId; + var reportFile = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + expectedReport.SetReportFile(reportFile); + + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(true); + + sutProvider.GetDependency() + .ReadLatestOrganizationReportAsync(orgId) + .Returns(expectedReport); + + sutProvider.GetDependency() + .GetReportDataDownloadUrlAsync(expectedReport, Arg.Any()) + .Returns("https://download-url"); + + // Act + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + Assert.IsType(result); + await sutProvider.GetDependency() + .Received(1) + .ReadLatestOrganizationReportAsync(orgId); + await sutProvider.GetDependency() + .DidNotReceive() + .GetLatestOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_FlagOff_CallsGetLatest( + SutProvider sutProvider, + Guid orgId, + OrganizationReport expectedReport) + { + // Arrange + expectedReport.OrganizationId = orgId; + expectedReport.ReportFile = null; + + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + Assert.IsType(result); + await sutProvider.GetDependency() + .Received(1) + .GetLatestOrganizationReportAsync(orgId); + await sutProvider.GetDependency() + .DidNotReceive() + .ReadLatestOrganizationReportAsync(Arg.Any()); + } + [Theory, BitAutoData] public async Task GetLatestOrganizationReportAsync_WithNoFile_ReturnsOkWithNullDownloadUrl( SutProvider sutProvider, @@ -1401,6 +1473,125 @@ await sutProvider.GetDependency() .CreateAsync(Arg.Any()); } + // DownloadReportFileAsync - validated file enforcement + + [Theory, BitAutoData] + public async Task DownloadReportFileAsync_FlagOn_UnvalidatedFile_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + OrganizationReport report) + { + // Arrange + report.OrganizationId = orgId; + var fileData = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false }; + report.SetReportFile(fileData); + + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.DownloadReportFileAsync(orgId, report.Id)); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetReportReadStreamAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task DownloadReportFileAsync_FlagOn_ValidatedFile_ReturnsFile( + SutProvider sutProvider, + Guid orgId, + OrganizationReport report) + { + // Arrange + report.OrganizationId = orgId; + var fileData = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + report.SetReportFile(fileData); + + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); + + var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + sutProvider.GetDependency() + .GetReportReadStreamAsync(report, Arg.Any()) + .Returns(stream); + + // Act + var result = await sutProvider.Sut.DownloadReportFileAsync(orgId, report.Id); + + // Assert + var fileResult = Assert.IsType(result); + Assert.Equal("application/octet-stream", fileResult.ContentType); + } + + [Theory, BitAutoData] + public async Task DownloadReportFileAsync_FlagOff_UnvalidatedFile_ReturnsFile( + SutProvider sutProvider, + Guid orgId, + OrganizationReport report) + { + // Arrange + report.OrganizationId = orgId; + var fileData = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = false }; + report.SetReportFile(fileData); + + SetupAuthorization(sutProvider, orgId); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.AccessIntelligenceVersion2) + .Returns(false); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); + + var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + sutProvider.GetDependency() + .GetReportReadStreamAsync(report, Arg.Any()) + .Returns(stream); + + // Act + var result = await sutProvider.Sut.DownloadReportFileAsync(orgId, report.Id); + + // Assert + Assert.IsType(result); + } + + // UploadReportFileAsync - re-upload guard + + [Theory, BitAutoData] + public async Task UploadReportFileAsync_AlreadyValidated_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + OrganizationReport report) + { + // Arrange + report.OrganizationId = orgId; + var fileData = new ReportFile { Id = "file-id", FileName = "report.json", Size = 1024, Validated = true }; + report.SetReportFile(fileData); + + SetupV2Authorization(sutProvider, orgId); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(report.Id) + .Returns(report); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.ContentType = "multipart/form-data"; + sutProvider.Sut.ControllerContext = new ControllerContext { HttpContext = httpContext }; + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.UploadReportFileAsync(orgId, report.Id, "file-id")); + } + // Helper methods for authorization mocks private static void SetupAuthorization( diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportQueryTests.cs new file mode 100644 index 000000000000..8989a5383cd8 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportQueryTests.cs @@ -0,0 +1,70 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +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 GetOrganizationReportQueryTests +{ + [Theory, BitAutoData] + public async Task ReadLatestOrganizationReportAsync_ReturnsRepoResult( + SutProvider sutProvider, + Guid orgId, + OrganizationReport expectedReport) + { + sutProvider.GetDependency() + .ReadLatestByOrganizationIdAsync(orgId) + .Returns(expectedReport); + + var result = await sutProvider.Sut.ReadLatestOrganizationReportAsync(orgId); + + Assert.Equal(expectedReport, result); + } + + [Theory, BitAutoData] + public async Task ReadLatestOrganizationReportAsync_NullResult_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId) + { + sutProvider.GetDependency() + .ReadLatestByOrganizationIdAsync(orgId) + .Returns((OrganizationReport)null); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.ReadLatestOrganizationReportAsync(orgId)); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_ReturnsRepoResult( + SutProvider sutProvider, + Guid orgId, + OrganizationReport expectedReport) + { + sutProvider.GetDependency() + .GetLatestByOrganizationIdAsync(orgId) + .Returns(expectedReport); + + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + Assert.Equal(expectedReport, result); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_NullResult_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId) + { + sutProvider.GetDependency() + .GetLatestByOrganizationIdAsync(orgId) + .Returns((OrganizationReport)null); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetLatestOrganizationReportAsync(orgId)); + } +} diff --git a/util/Migrator/DbScripts/2026-05-19_00_AddOrganizationReportReadLatestSp.sql b/util/Migrator/DbScripts/2026-05-19_00_AddOrganizationReportReadLatestSp.sql new file mode 100644 index 000000000000..e70550d0743a --- /dev/null +++ b/util/Migrator/DbScripts/2026-05-19_00_AddOrganizationReportReadLatestSp.sql @@ -0,0 +1,17 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_ReadLatestByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT TOP 1 + * + FROM + [dbo].[OrganizationReportView] + WHERE + [OrganizationId] = @OrganizationId + AND JSON_VALUE([ReportFile], '$.Validated') = 'true' + ORDER BY + [RevisionDate] DESC +END +GO diff --git a/util/Migrator/DbScripts/2026-05-19_01_UpdateOrganizationReportGetLatestSkipEmptyReportData.sql b/util/Migrator/DbScripts/2026-05-19_01_UpdateOrganizationReportGetLatestSkipEmptyReportData.sql new file mode 100644 index 000000000000..e2a531e41a01 --- /dev/null +++ b/util/Migrator/DbScripts/2026-05-19_01_UpdateOrganizationReportGetLatestSkipEmptyReportData.sql @@ -0,0 +1,17 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_GetLatestByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT TOP 1 + * + FROM + [dbo].[OrganizationReportView] + WHERE + [OrganizationId] = @OrganizationId + AND [ReportData] <> '' + ORDER BY + [RevisionDate] DESC +END +GO