Skip to content
191 changes: 191 additions & 0 deletions src/Api/Dirt/Controllers/OrganizationReportsV2Controller.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
ο»Ώ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<OrganizationReportV2ResponseModel> 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<OrganizationReportResponseModel> 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<OrganizationReportV2ResponseModel> 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
ο»Ώusing Bit.Core.Enums;
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!;
public FileUploadType FileUploadType { get; set; }
}
17 changes: 17 additions & 0 deletions src/Core/Dirt/Entities/OrganizationReport.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -27,8 +30,22 @@ public class OrganizationReport : ITableObject<Guid>
public int? PasswordAtRiskCount { get; set; }
public int? CriticalPasswordCount { get; set; }
public int? CriticalPasswordAtRiskCount { get; set; }
public OrganizationReportType Type { get; set; }

public OrganizationReportFileData? GetReportFileData()
{
if (string.IsNullOrWhiteSpace(ReportData))
{
return null;
}

return JsonSerializer.Deserialize<OrganizationReportFileData>(ReportData);
}

public void SetReportFileData(OrganizationReportFileData data)
{
ReportData = JsonSerializer.Serialize(data, JsonHelpers.IgnoreWritingNull);
}

public void SetNewId()
{
Expand Down
7 changes: 7 additions & 0 deletions src/Core/Dirt/Enums/OrganizationReportType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ο»Ώnamespace Bit.Core.Dirt.Enums;

public enum OrganizationReportType : byte
{
Data = 0,
File = 1
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ο»Ώnamespace Bit.Core.Dirt.Models.Data;

public class OrganizationReportDataFileStorageResponse
{
public string DownloadUrl { get; set; } = string.Empty;
}
20 changes: 20 additions & 0 deletions src/Core/Dirt/Models/Data/OrganizationReportFileData.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,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.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;
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<CreateOrganizationReportV2Command> _logger;

public CreateOrganizationReportV2Command(
IOrganizationRepository organizationRepository,
IOrganizationReportRepository organizationReportRepository,
ILogger<CreateOrganizationReportV2Command> 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 OrganizationReportFileData
{
Id = CoreHelpers.SecureRandomString(32, upper: false, special: false),
Validated = false
};

var organizationReport = new OrganizationReport
{
OrganizationId = request.OrganizationId,
Type = OrganizationReportType.File,
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.SetReportFileData(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);
}
}
Loading
Loading