1
0
mirror of https://github.com/bitwarden/server synced 2026-01-06 02:23:51 +00:00

Organization report tables, repos, services, and endpoints (#6158)

* PM-23754 initial commit

* pm-23754 fixing controller tests

* pm-23754 adding commands and queries

* pm-23754 adding endpoints, command/queries, repositories, and sql migrations

* pm-23754 add new sql scripts

* PM-23754 adding sql scripts

* pm-23754

* PM-23754 fixing migration script

* PM-23754 fixing migration script again

* PM-23754 fixing migration script validation

* PM-23754 fixing db validation script issue

* PM-23754 fixing endpoint and db validation

* PM-23754 fixing unit tests

* PM-23754 fixing implementation based on comments and tests

* PM-23754 updating logging statements

* PM-23754 making changes based on PR comments.

* updating migration scripts

* removing old migration files

* update code based testing for whole data object for OrganizationReport and add a stored procedure.

* updating services, unit tests, repository tests

* fixing unit tests

* fixing migration script

* fixing migration script again

* fixing migration script

* another fix

* fixing sql file, updating controller to account for different orgIds in the url and body.

* updating error message in controllers without a body

* making a change to the command

* Refactor ReportsController by removing organization reports

The IDropOrganizationReportCommand is no longer needed

* will code based on PR comments.

* fixing unit test

* fixing migration script based on last changes.

* adding another check in endpoint and adding unit tests

* fixing route parameter.

* PM-23754 updating data fields to return just the column

* PM-23754 fixing repository method signatures

* PM-23754 making change to orgId parameter through out code to align with api naming

---------

Co-authored-by: Tom <144813356+ttalty@users.noreply.github.com>
This commit is contained in:
Graham Walker
2025-09-08 15:06:13 -05:00
committed by GitHub
parent cb0d5a5ba6
commit 226f274a72
79 changed files with 24744 additions and 1047 deletions

View File

@@ -0,0 +1,297 @@
using Bit.Core.Context;
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
using Bit.Core.Exceptions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Dirt.Controllers;
[Route("reports/organizations")]
[Authorize("Application")]
public class OrganizationReportsController : Controller
{
private readonly ICurrentContext _currentContext;
private readonly IGetOrganizationReportQuery _getOrganizationReportQuery;
private readonly IAddOrganizationReportCommand _addOrganizationReportCommand;
private readonly IUpdateOrganizationReportCommand _updateOrganizationReportCommand;
private readonly IUpdateOrganizationReportSummaryCommand _updateOrganizationReportSummaryCommand;
private readonly IGetOrganizationReportSummaryDataQuery _getOrganizationReportSummaryDataQuery;
private readonly IGetOrganizationReportSummaryDataByDateRangeQuery _getOrganizationReportSummaryDataByDateRangeQuery;
private readonly IGetOrganizationReportDataQuery _getOrganizationReportDataQuery;
private readonly IUpdateOrganizationReportDataCommand _updateOrganizationReportDataCommand;
private readonly IGetOrganizationReportApplicationDataQuery _getOrganizationReportApplicationDataQuery;
private readonly IUpdateOrganizationReportApplicationDataCommand _updateOrganizationReportApplicationDataCommand;
public OrganizationReportsController(
ICurrentContext currentContext,
IGetOrganizationReportQuery getOrganizationReportQuery,
IAddOrganizationReportCommand addOrganizationReportCommand,
IUpdateOrganizationReportCommand updateOrganizationReportCommand,
IUpdateOrganizationReportSummaryCommand updateOrganizationReportSummaryCommand,
IGetOrganizationReportSummaryDataQuery getOrganizationReportSummaryDataQuery,
IGetOrganizationReportSummaryDataByDateRangeQuery getOrganizationReportSummaryDataByDateRangeQuery,
IGetOrganizationReportDataQuery getOrganizationReportDataQuery,
IUpdateOrganizationReportDataCommand updateOrganizationReportDataCommand,
IGetOrganizationReportApplicationDataQuery getOrganizationReportApplicationDataQuery,
IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicationDataCommand
)
{
_currentContext = currentContext;
_getOrganizationReportQuery = getOrganizationReportQuery;
_addOrganizationReportCommand = addOrganizationReportCommand;
_updateOrganizationReportCommand = updateOrganizationReportCommand;
_updateOrganizationReportSummaryCommand = updateOrganizationReportSummaryCommand;
_getOrganizationReportSummaryDataQuery = getOrganizationReportSummaryDataQuery;
_getOrganizationReportSummaryDataByDateRangeQuery = getOrganizationReportSummaryDataByDateRangeQuery;
_getOrganizationReportDataQuery = getOrganizationReportDataQuery;
_updateOrganizationReportDataCommand = updateOrganizationReportDataCommand;
_getOrganizationReportApplicationDataQuery = getOrganizationReportApplicationDataQuery;
_updateOrganizationReportApplicationDataCommand = updateOrganizationReportApplicationDataCommand;
}
#region Whole OrganizationReport Endpoints
[HttpGet("{organizationId}/latest")]
public async Task<IActionResult> GetLatestOrganizationReportAsync(Guid organizationId)
{
if (!await _currentContext.AccessReports(organizationId))
{
throw new NotFoundException();
}
var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId);
return Ok(latestReport);
}
[HttpGet("{organizationId}/{reportId}")]
public async Task<IActionResult> GetOrganizationReportAsync(Guid organizationId, Guid reportId)
{
if (!await _currentContext.AccessReports(organizationId))
{
throw new NotFoundException();
}
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");
}
return Ok(report);
}
[HttpPost("{organizationId}")]
public async Task<IActionResult> CreateOrganizationReportAsync(Guid organizationId, [FromBody] AddOrganizationReportRequest 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 report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request);
return Ok(report);
}
[HttpPatch("{organizationId}/{reportId}")]
public async Task<IActionResult> 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);
return Ok(updatedReport);
}
#endregion
# region SummaryData Field Endpoints
[HttpGet("{organizationId}/data/summary")]
public async Task<IActionResult> GetOrganizationReportSummaryDataByDateRangeAsync(
Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
{
if (!await _currentContext.AccessReports(organizationId))
{
throw new NotFoundException();
}
if (organizationId.Equals(null))
{
throw new BadRequestException("Organization ID is required.");
}
var summaryDataList = await _getOrganizationReportSummaryDataByDateRangeQuery
.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate);
return Ok(summaryDataList);
}
[HttpGet("{organizationId}/data/summary/{reportId}")]
public async Task<IActionResult> 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);
}
[HttpPatch("{organizationId}/data/summary/{reportId}")]
public async Task<IActionResult> 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);
return Ok(updatedReport);
}
#endregion
#region ReportData Field Endpoints
[HttpGet("{organizationId}/data/report/{reportId}")]
public async Task<IActionResult> 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/report/{reportId}")]
public async Task<IActionResult> 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);
return Ok(updatedReport);
}
#endregion
#region ApplicationData Field Endpoints
[HttpGet("{organizationId}/data/application/{reportId}")]
public async Task<IActionResult> 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/application/{reportId}")]
public async Task<IActionResult> 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);
return Ok(updatedReport);
}
catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException))
{
throw;
}
}
#endregion
}

View File

@@ -25,7 +25,6 @@ public class ReportsController : Controller
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand;
private readonly IAddOrganizationReportCommand _addOrganizationReportCommand;
private readonly IDropOrganizationReportCommand _dropOrganizationReportCommand;
private readonly IGetOrganizationReportQuery _getOrganizationReportQuery;
private readonly ILogger<ReportsController> _logger;
@@ -38,7 +37,6 @@ public class ReportsController : Controller
IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand,
IGetOrganizationReportQuery getOrganizationReportQuery,
IAddOrganizationReportCommand addOrganizationReportCommand,
IDropOrganizationReportCommand dropOrganizationReportCommand,
ILogger<ReportsController> logger
)
{
@@ -50,7 +48,6 @@ public class ReportsController : Controller
_dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand;
_getOrganizationReportQuery = getOrganizationReportQuery;
_addOrganizationReportCommand = addOrganizationReportCommand;
_dropOrganizationReportCommand = dropOrganizationReportCommand;
_logger = logger;
}
@@ -209,195 +206,4 @@ public class ReportsController : Controller
await _dropPwdHealthReportAppCommand.DropPasswordHealthReportApplicationAsync(request);
}
/// <summary>
/// Adds a new organization report
/// </summary>
/// <param name="request">A single instance of AddOrganizationReportRequest</param>
/// <returns>A single instance of OrganizationReport</returns>
/// <exception cref="NotFoundException">If user does not have access to the organization</exception>
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
[HttpPost("organization-reports")]
public async Task<OrganizationReport> AddOrganizationReport([FromBody] AddOrganizationReportRequest request)
{
if (!await _currentContext.AccessReports(request.OrganizationId))
{
throw new NotFoundException();
}
return await _addOrganizationReportCommand.AddOrganizationReportAsync(request);
}
/// <summary>
/// Drops organization reports for an organization
/// </summary>
/// <param name="request">A single instance of DropOrganizationReportRequest</param>
/// <returns></returns>
/// <exception cref="NotFoundException">If user does not have access to the organization</exception>
/// <exception cref="BadRequestException">If the organization does not have any records</exception>
[HttpDelete("organization-reports")]
public async Task DropOrganizationReport([FromBody] DropOrganizationReportRequest request)
{
if (!await _currentContext.AccessReports(request.OrganizationId))
{
throw new NotFoundException();
}
await _dropOrganizationReportCommand.DropOrganizationReportAsync(request);
}
/// <summary>
/// Gets organization reports for an organization
/// </summary>
/// <param name="orgId">A valid Organization Id</param>
/// <returns>An Enumerable of OrganizationReport</returns>
/// <exception cref="NotFoundException">If user does not have access to the organization</exception>
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
[HttpGet("organization-reports/{orgId}")]
public async Task<IEnumerable<OrganizationReport>> GetOrganizationReports(Guid orgId)
{
if (!await _currentContext.AccessReports(orgId))
{
throw new NotFoundException();
}
return await _getOrganizationReportQuery.GetOrganizationReportAsync(orgId);
}
/// <summary>
/// Gets the latest organization report for an organization
/// </summary>
/// <param name="orgId">A valid Organization Id</param>
/// <returns>A single instance of OrganizationReport</returns>
/// <exception cref="NotFoundException">If user does not have access to the organization</exception>
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
[HttpGet("organization-reports/latest/{orgId}")]
public async Task<OrganizationReport> GetLatestOrganizationReport(Guid orgId)
{
if (!await _currentContext.AccessReports(orgId))
{
throw new NotFoundException();
}
return await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(orgId);
}
/// <summary>
/// Gets the Organization Report Summary for an organization.
/// This includes the latest report's encrypted data, encryption key, and date.
/// This is a mock implementation and should be replaced with actual data retrieval logic.
/// </summary>
/// <param name="orgId"></param>
/// <param name="from">Min date (example: 2023-01-01)</param>
/// <param name="to">Max date (example: 2023-12-31)</param>
/// <returns></returns>
/// <exception cref="NotFoundException"></exception>
[HttpGet("organization-report-summary/{orgId}")]
public IEnumerable<OrganizationReportSummaryModel> GetOrganizationReportSummary(
[FromRoute] Guid orgId,
[FromQuery] DateOnly from,
[FromQuery] DateOnly to)
{
if (!ModelState.IsValid)
{
throw new BadRequestException(ModelState);
}
GuardOrganizationAccess(orgId);
// FIXME: remove this mock class when actual data retrieval is implemented
return MockOrganizationReportSummary.GetMockData()
.Where(_ => _.OrganizationId == orgId
&& _.Date >= from.ToDateTime(TimeOnly.MinValue)
&& _.Date <= to.ToDateTime(TimeOnly.MaxValue));
}
/// <summary>
/// Creates a new Organization Report Summary for an organization.
/// This is a mock implementation and should be replaced with actual creation logic.
/// </summary>
/// <param name="model"></param>
/// <returns>Returns 204 Created with the created OrganizationReportSummaryModel</returns>
/// <exception cref="NotFoundException"></exception>
[HttpPost("organization-report-summary")]
public IActionResult CreateOrganizationReportSummary([FromBody] OrganizationReportSummaryModel model)
{
if (!ModelState.IsValid)
{
throw new BadRequestException(ModelState);
}
GuardOrganizationAccess(model.OrganizationId);
// TODO: Implement actual creation logic
// Returns 204 No Content as a placeholder
return NoContent();
}
[HttpPut("organization-report-summary")]
public IActionResult UpdateOrganizationReportSummary([FromBody] OrganizationReportSummaryModel model)
{
if (!ModelState.IsValid)
{
throw new BadRequestException(ModelState);
}
GuardOrganizationAccess(model.OrganizationId);
// TODO: Implement actual update logic
// Returns 204 No Content as a placeholder
return NoContent();
}
private void GuardOrganizationAccess(Guid organizationId)
{
if (!_currentContext.AccessReports(organizationId).Result)
{
throw new NotFoundException();
}
}
// FIXME: remove this mock class when actual data retrieval is implemented
private class MockOrganizationReportSummary
{
public static List<OrganizationReportSummaryModel> GetMockData()
{
return new List<OrganizationReportSummaryModel>
{
new OrganizationReportSummaryModel
{
OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"),
EncryptedData = "2.EtCcxDEBoF1MYChYHC4Q1w==|RyZ07R7qEFBbc/ICLFpEMockL9K+PD6rOod6DGHHrkaRLHUDqDwmxbu3jnD0cg8s7GIYmp0jApHXC+82QdApk87pA0Kr8fN2Rj0+8bDQCjhKfoRTipAB25S/n2E+ttjvlFfag92S66XqUH9S/eZw/Q==|0bPfykHk3SqS/biLNcNoYtH6YTstBEKu3AhvdZZLxhU=",
EncryptionKey = "2.Dd/TtdNwxWdYg9+fRkxh6w==|8KAiK9SoadgFRmyVOchd4tNh2vErD1Rv9x1gqtsE5tzxKE/V/5kkr1WuVG+QpEj//YaQt221UEMESRSXicZ7a9cB6xXLBkbbFwmecQRJVBs=|902em44n9cwciZzYrYuX6MRzRa+4hh1HHfNAxyJx/IM=",
Date = DateTime.UtcNow
},
new OrganizationReportSummaryModel
{
OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"),
EncryptedData = "2.HvY4fAvbzYV1hqa3255m5Q==|WcKga2Wka5i8fVso8MgjzfBAwxaqdhZDL3bnvhDsisZ0r9lNKQcG3YUQSFpJxr74cgg5QRQaFieCUe2YppciHDT6bsaE2VzFce3cNNB821uTFqnlJClkGJpG1nGvPupdErrg4Ik57WenEzYesmR4pw==|F0aJfF+1MlPm+eAlQnDgFnwfv198N9VtPqFJa4+UFqk=",
EncryptionKey = "2.ctMgLN4ycPusbQArG/uiag==|NtqiQsAoUxMSTBQsxAMyVLWdt5lVEUGZQNxZSBU4l76ywH2f6dx5FWFrcF3t3GBqy5yDoc5eBg0VlJDW9coqzp8j9n8h1iMrtmXPyBMAhbc=|pbH+w68BUdUKYCfNRpjd8NENw2lZ0vfxgMuTrsrRCTQ=",
Date = DateTime.UtcNow.AddMonths(-1)
},
new OrganizationReportSummaryModel
{
OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"),
EncryptedData = "2.NH4qLZYUkz/+qpB/mRsLTA==|LEFt05jJz0ngh+Hl5lqk6kebj7lZMefA3eFdL1kLJSGdD3uTOngRwH7GXLQNFeQOxutnLX9YUILbUEPwaM8gCwNQ1KWYdB1Z+Ky4nzKRb60N7L5aTA2za6zXTIdjv7Zwhg0jPZ6sPevTuvSyqjMCuA==|Uuu6gZaF0wvB2mHFwtvHegMxfe8DgsYWTRfGiVn4lkM=",
EncryptionKey = "2.3YwG78ykSxAn44NcymdG4w==|4jfn0nLoFielicAFbmq27DNUUjV4SwGePnjYRmOa7hk4pEPnQRS3MsTJFbutVyXOgKFY9Yn2yGFZownY9EmXOMM+gHPD0t6TfzUKqQcRyuI=|wasP9zZEL9mFH5HzJYrMxnKUr/XlFKXCxG9uW66uaPU=",
Date = DateTime.UtcNow.AddMonths(-1)
},
new OrganizationReportSummaryModel
{
OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"),
EncryptedData = "2.YmKWj/707wDPONh+JXPBOw==|Fx4jcUHmnUnSMCU8vdThMSYpDyKPnC09TxpSbNxia0M6MFbd5WHElcVribrYgTENyU0HlqPW43hThJ6xXCM0EjEWP7/jb/0l07vMNkA7sDYq+czf0XnYZgZSGKh06wFVz8xkhaPTdsiO4CXuMsoH+w==|DDVwVFHzdfbPQe3ycCx82eYVHDW97V/eWTPsNpHX/+U=",
EncryptionKey = "2.f/U45I7KF+JKfnvOArUyaw==|zNhhS2q2WwBl6SqLWMkxrXC8EX91Ra9LJExywkJhsRbxubRLt7fK+YWc8T1LUaDmMwJ3G8buSPGzyacKX0lnUR33dW6DIaLNgRZ/ekb/zkg=|qFoIZWwS0foiiIOyikFRwQKmmmI2HeyHcOVklJnIILI=",
Date = DateTime.UtcNow.AddMonths(-1)
},
new OrganizationReportSummaryModel
{
OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"),
EncryptedData = "2.WYauwooJUEY3kZsDPphmrA==|oguYW6h10A4GxK4KkRS0X32qSTekU2CkGqNDNGfisUgvJzsyoVTafO9sVcdPdg4BUM7YNkPMjYiKEc5jMHkIgLzbnM27jcGvMJrrccSrLHiWL6/mEiqQkV3TlfiZF9i3wqj1ITsYRzM454uNle6Wrg==|uR67aFYb1i5LSidWib0iTf8091l8GY5olHkVXse3CAw=",
EncryptionKey = "2.ZyV9+9A2cxNaf8dfzfbnlA==|hhorBpVkcrrhTtNmd6SNHYI8gPNokGLOC22Vx8Qa/AotDAcyuYWw56zsawMnzpAdJGEJFtszKM2+VUVOcroCTMWHpy8yNf/kZA6uPk3Lz3s=|ASzVeJf+K1ZB8NXuypamRBGRuRq0GUHZBEy5r/O7ORY=",
Date = DateTime.UtcNow.AddMonths(-1)
},
};
}
}
}