Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
99bc641
pm-31920 adding the whole report endpoints v2
prograhamming Feb 25, 2026
8002621
Merge branch 'main' into dirt/pm-31920-whole-report-data-v2-endpoints…
prograhamming Feb 25, 2026
d9b1bb7
Merge branch 'main' into dirt/pm-31920-whole-report-data-v2-endpoints…
prograhamming Feb 25, 2026
5d3f7b7
Merge branch 'main' into dirt/pm-31920-whole-report-data-v2-endpoints…
prograhamming Feb 26, 2026
65092cb
pm-31920 changing approach to match others in codebase
prograhamming Feb 26, 2026
b6def67
Merge branch 'dirt/pm-31920-whole-report-data-v2-endpoints-access-int…
prograhamming Feb 26, 2026
ffa2e80
Merge branch 'main' into dirt/pm-31920-whole-report-data-v2-endpoints…
prograhamming Mar 1, 2026
5852cba
31920 updating code to now use the ReportFile field
prograhamming Mar 4, 2026
0abca22
Merge branch 'dirt/pm-31920-whole-report-data-v2-endpoints-access-int…
prograhamming Mar 4, 2026
ed8116d
Merge branch 'main' into dirt/pm-31920-whole-report-data-v2-endpoints…
prograhamming Mar 4, 2026
a41cbf5
Merge branch 'main' into dirt/pm-31920-whole-report-data-v2-endpoints…
prograhamming Mar 4, 2026
afe7c61
PM-31920 adding request size attributes
prograhamming Mar 5, 2026
e5e3b68
Merge branch 'main' into dirt/pm-31920-whole-report-data-v2-endpoints…
prograhamming Mar 5, 2026
f821b55
PM-31920 fixing all the endpoints
prograhamming Mar 5, 2026
a2f6db2
PM-31920 remove claude change
prograhamming Mar 5, 2026
6b6f857
Merge branch 'main' into dirt/pm-31920-whole-report-data-v2-endpoints…
prograhamming Mar 5, 2026
f0e0b51
PM-31920 fixing feature flag name
prograhamming Mar 5, 2026
d9d3e1b
Merge branch 'dirt/pm-31920-whole-report-data-v2-endpoints-access-int…
prograhamming Mar 5, 2026
c65a75e
Merge branch 'main' into dirt/pm-31920-whole-report-data-v2-endpoints…
prograhamming Mar 5, 2026
afe4df2
Merge branch 'main' into dirt/pm-31920-whole-report-data-v2-endpoints…
prograhamming Mar 5, 2026
5326782
PM-31920 fixing path traversal vuln and cleaned up null references
prograhamming Mar 6, 2026
0e48567
PM-31920 fixing unit test
prograhamming Mar 6, 2026
ad38c67
PM-31920 fixing issues found by reviewer
prograhamming Mar 6, 2026
1c4e37f
PM-31920 addressing pr comments
prograhamming Mar 6, 2026
37b79c6
Merge branch 'main' into dirt/pm-31920-whole-report-data-v2-endpoints…
prograhamming Mar 6, 2026
4bb40d0
PM-31920 fixing issues based on review
prograhamming Mar 6, 2026
adaf501
PM-31920 removing settings.json
prograhamming Mar 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
492 changes: 382 additions & 110 deletions src/Api/Dirt/Controllers/OrganizationReportsController.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Bit.Core.Enums;

namespace Bit.Api.Dirt.Models.Response;

public class OrganizationReportFileResponseModel
{
public OrganizationReportFileResponseModel() { }

public string ReportFileUploadUrl { get; set; } = string.Empty;
public OrganizationReportResponseModel ReportResponse { get; set; } = null!;
public FileUploadType FileUploadType { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Models.Data;

namespace Bit.Api.Dirt.Models.Response;

Expand All @@ -10,11 +11,10 @@ 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 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)
{
Expand All @@ -29,9 +29,7 @@ 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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Information leak: The ReportFile object (containing internal fields like Id, Size, Validated, FileName) is exposed directly to API consumers via the response model. The Id is a CoreHelpers.SecureRandomString that's used as a path component for blob storage — leaking it gives clients knowledge of internal storage paths.

Consider either removing this property from the response model (the download URL is already exposed via ReportFileDownloadUrl), or creating a slimmed-down DTO that only exposes what the client needs (e.g., FileName and Size).

CreationDate = organizationReport.CreationDate;
RevisionDate = organizationReport.RevisionDate;
}
Comment on lines 19 to 35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The response model only maps a subset of the entity's metric fields (PasswordCount, PasswordAtRiskCount, MemberCount). The entity has 12 metric fields (application, member, password, each with total/atRisk/critical/criticalAtRisk). If the client needs these metrics, the response model should map all of them. If they're intentionally excluded from the API response, a comment explaining why would help future maintainers.

Expand Down
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ public static class FeatureFlagKeys
public const string ArchiveVaultItems = "pm-19148-innovation-archive";

/* DIRT Team */
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";
Expand Down
19 changes: 17 additions & 2 deletions src/Core/Dirt/Entities/OrganizationReport.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#nullable enable

using System.Text.Json;
using Bit.Core.Dirt.Models.Data;
using Bit.Core.Entities;
using Bit.Core.Utilities;

Expand Down Expand Up @@ -29,6 +29,21 @@ public class OrganizationReport : ITableObject<Guid>
public int? CriticalPasswordAtRiskCount { get; set; }
public string? ReportFile { get; set; }

public ReportFile? GetReportFile()
{
if (string.IsNullOrWhiteSpace(ReportFile))
{
return null;
}

return JsonSerializer.Deserialize<ReportFile>(ReportFile);
}

public void SetReportFile(ReportFile data)
{
ReportFile = JsonSerializer.Serialize(data, JsonHelpers.IgnoreWritingNull);
}

public void SetNewId()
{
Id = CoreHelpers.GenerateComb();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
6 changes: 2 additions & 4 deletions src/Core/Dirt/Models/Data/ReportFile.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
#nullable enable

using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using static System.Text.Json.Serialization.JsonNumberHandling;

Expand Down Expand Up @@ -28,5 +26,5 @@ public class ReportFile
/// <summary>
/// When true the uploaded file's length has been validated.
/// </summary>
public bool Validated { get; set; } = true;
public bool Validated { get; set; } = false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public async Task<OrganizationReport> AddOrganizationReportAsync(AddOrganization
throw new BadRequestException(errorMessage);
}

var requestMetrics = request.Metrics ?? new OrganizationReportMetricsRequest();
var requestMetrics = request.ReportMetrics ?? new OrganizationReportMetrics();

var organizationReport = new OrganizationReport
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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 CreateOrganizationReportCommand : ICreateOrganizationReportCommand
{
private readonly IOrganizationRepository _organizationRepo;
private readonly IOrganizationReportRepository _organizationReportRepo;
private readonly ILogger<CreateOrganizationReportCommand> _logger;

public CreateOrganizationReportCommand(
IOrganizationRepository organizationRepository,
IOrganizationReportRepository organizationReportRepository,
ILogger<CreateOrganizationReportCommand> logger)
{
_organizationRepo = organizationRepository;
_organizationReportRepo = organizationReportRepository;
_logger = logger;
}

public async Task<OrganizationReport> 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 fileData = new ReportFile
{
Id = CoreHelpers.SecureRandomString(32, upper: false, special: false),
FileName = "report-data.json",
Size = request.FileSize ?? 0,
Validated = false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT (Security): Both this command and UpdateOrganizationReportV2Command correctly set Validated = false for new files. However, the ReportFile class (src/Core/Dirt/Models/Data/ReportFile.cs line 29) declares the default as public bool Validated { get; set; } = true;.

This is a fail-open default for a security-sensitive field. If System.Text.Json deserialization ever produces a ReportFile instance without an explicit Validated value (corrupted JSON, a field missing from the serialized data, etc.), the file would be treated as validated, and the controller would generate a download URL for a potentially unverified file.

The safe default should be false (fail-closed). Since both creation paths already set Validated = false explicitly, and the validation paths set it to true explicitly, changing the default in ReportFile.cs to false will not affect normal code flow but will protect against edge cases.

};

var organizationReport = new OrganizationReport
{
OrganizationId = request.OrganizationId,
CreationDate = DateTime.UtcNow,
ContentEncryptionKey = request.ContentEncryptionKey ?? string.Empty,
SummaryData = request.SummaryData,
ApplicationData = request.ApplicationData,
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
};
organizationReport.SetReportFile(fileData);

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,42 +21,23 @@ public GetOrganizationReportApplicationDataQuery(

public async Task<OrganizationReportApplicationDataResponse> GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId)
{
try
if (organizationId == Guid.Empty)
{
_logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report application data for organization {organizationId} and report {reportId}",
organizationId, 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;
throw new BadRequestException("OrganizationId is required.");
}
catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException))

if (reportId == Guid.Empty)
{
throw new BadRequestException("ReportId is required.");
}

var applicationDataResponse = await _organizationReportRepo.GetApplicationDataAsync(reportId);

if (applicationDataResponse == null)
{
_logger.LogError(ex, "Error fetching organization report application data for organization {organizationId} and report {reportId}",
organizationId, reportId);
throw;
throw new NotFoundException("Organization report application data not found.");
}

return applicationDataResponse;
}
}
Original file line number Diff line number Diff line change
@@ -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 ICreateOrganizationReportCommand
{
Task<OrganizationReport> CreateAsync(AddOrganizationReportRequest request);
}
Original file line number Diff line number Diff line change
@@ -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<OrganizationReport> UpdateAsync(UpdateOrganizationReportV2Request request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Bit.Core.Dirt.Entities;

namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;

public interface IValidateOrganizationReportFileCommand
{
Task<bool> ValidateAsync(OrganizationReport report, string reportFileId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,10 @@ public static void AddReportingServices(this IServiceCollection services, IGloba
services.AddScoped<IUpdateOrganizationReportDataCommand, UpdateOrganizationReportDataCommand>();
services.AddScoped<IGetOrganizationReportApplicationDataQuery, GetOrganizationReportApplicationDataQuery>();
services.AddScoped<IUpdateOrganizationReportApplicationDataCommand, UpdateOrganizationReportApplicationDataCommand>();

// v2 file storage commands
services.AddScoped<ICreateOrganizationReportCommand, CreateOrganizationReportCommand>();
services.AddScoped<IUpdateOrganizationReportV2Command, UpdateOrganizationReportV2Command>();
services.AddScoped<IValidateOrganizationReportFileCommand, ValidateOrganizationReportFileCommand>();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,10 @@ public class AddOrganizationReportRequest

public string? ApplicationData { get; set; }

public OrganizationReportMetricsRequest? Metrics { get; set; }
public OrganizationReportMetrics? ReportMetrics { get; set; }

/// <summary>
/// Estimated size of the report file in bytes. Required for v2 reports.
/// </summary>
public long? FileSize { get; set; }
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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; }

/// <summary>
/// Estimated size of the report file in bytes. Required when RequiresNewFileUpload is true.
/// </summary>
public long? FileSize { get; set; }
}
Loading
Loading