Skip to content
19 changes: 9 additions & 10 deletions src/Api/Dirt/Controllers/OrganizationReportsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
using Bit.Api.Dirt.Models.Response;
using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.AdminConsole.AbilitiesCache;
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.Services;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
Expand All @@ -32,7 +32,6 @@ public class OrganizationReportsController : Controller
private readonly IGetOrganizationReportApplicationDataQuery _getOrganizationReportApplicationDataQuery;
private readonly IUpdateOrganizationReportApplicationDataCommand _updateOrganizationReportApplicationDataCommand;
private readonly IFeatureService _featureService;
private readonly IOrganizationAbilityCacheService _organizationAbilityCacheService;
private readonly IOrganizationReportStorageService _storageService;
private readonly ICreateOrganizationReportCommand _createReportCommand;
private readonly IOrganizationReportRepository _organizationReportRepo;
Expand All @@ -51,7 +50,6 @@ public OrganizationReportsController(
IGetOrganizationReportApplicationDataQuery getOrganizationReportApplicationDataQuery,
IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicationDataCommand,
IFeatureService featureService,
IOrganizationAbilityCacheService organizationAbilityCacheService,
IOrganizationReportStorageService storageService,
ICreateOrganizationReportCommand createReportCommand,
IOrganizationReportRepository organizationReportRepo,
Expand All @@ -69,7 +67,6 @@ public OrganizationReportsController(
_getOrganizationReportApplicationDataQuery = getOrganizationReportApplicationDataQuery;
_updateOrganizationReportApplicationDataCommand = updateOrganizationReportApplicationDataCommand;
_featureService = featureService;
_organizationAbilityCacheService = organizationAbilityCacheService;
_storageService = storageService;
_createReportCommand = createReportCommand;
_organizationReportRepo = organizationReportRepo;
Expand All @@ -90,6 +87,7 @@ public OrganizationReportsController(
/// <returns>An <see cref="OrganizationReportFileResponseModel"/> with upload URL when the request
/// includes a file size, or an <see cref="OrganizationReportResponseModel"/> otherwise.</returns>
[HttpPost("{organizationId}")]
[RequireOrganizationAbility(nameof(OrganizationAbility.UseRiskInsights))]
public async Task<IActionResult> CreateOrganizationReportAsync(
Guid organizationId,
[FromBody] AddOrganizationReportRequestModel request)
Expand Down Expand Up @@ -140,6 +138,7 @@ public async Task<IActionResult> CreateOrganizationReportAsync(
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <returns>An <see cref="OrganizationReportResponseModel"/> for the most recent report.</returns>
[HttpGet("{organizationId}/latest")]
[RequireOrganizationAbility(nameof(OrganizationAbility.UseRiskInsights))]
public async Task<IActionResult> GetLatestOrganizationReportAsync(Guid organizationId)
{
EnsureValidIds(organizationId);
Expand Down Expand Up @@ -176,6 +175,7 @@ public async Task<IActionResult> GetLatestOrganizationReportAsync(Guid organizat
/// <param name="reportId">The unique identifier of the report to retrieve.</param>
/// <returns>An <see cref="OrganizationReportResponseModel"/> matching the specified IDs.</returns>
[HttpGet("{organizationId}/{reportId}")]
[RequireOrganizationAbility(nameof(OrganizationAbility.UseRiskInsights))]
public async Task<IActionResult> GetOrganizationReportAsync(Guid organizationId, Guid reportId)
{
var report = await GetAuthorizedReportAsync(organizationId, reportId);
Expand Down Expand Up @@ -208,6 +208,7 @@ public async Task<IActionResult> GetOrganizationReportAsync(Guid organizationId,
/// <returns>An <see cref="OrganizationReportResponseModel"/> with the updated report.</returns>
[HttpPatch("{organizationId}/{reportId}")]
[RequireFeature(FeatureFlagKeys.AccessIntelligenceNewArchitecture)]
[RequireOrganizationAbility(nameof(OrganizationAbility.UseRiskInsights))]
public async Task<IActionResult> UpdateOrganizationReportAsync(
Guid organizationId,
Guid reportId,
Expand Down Expand Up @@ -236,6 +237,7 @@ public async Task<IActionResult> UpdateOrganizationReportAsync(
[ProducesResponseType<IEnumerable<OrganizationReportSummaryDataResponse>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[RequireOrganizationAbility(nameof(OrganizationAbility.UseRiskInsights))]
public async Task<IActionResult> GetOrganizationReportSummaryDataByDateRangeAsync(
Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate)
{
Expand All @@ -256,6 +258,7 @@ public async Task<IActionResult> GetOrganizationReportSummaryDataByDateRangeAsyn
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="reportId">The unique identifier of the report to delete.</param>
[HttpDelete("{organizationId}/{reportId}")]
[RequireOrganizationAbility(nameof(OrganizationAbility.UseRiskInsights))]
public async Task DeleteOrganizationReportAsync(Guid organizationId, Guid reportId)
{
var report = await GetAuthorizedReportAsync(organizationId, reportId);
Expand Down Expand Up @@ -382,6 +385,7 @@ public async Task<ObjectResult> AzureValidateFileAsync()
[SelfHosted(SelfHostedOnly = true)]
[RequestSizeLimit(Constants.FileSize501mb)]
[DisableFormValueModelBinding]
[RequireOrganizationAbility(nameof(OrganizationAbility.UseRiskInsights))]
public async Task UploadReportFileAsync(Guid organizationId, Guid reportId, [FromQuery] string reportFileId)
{
var report = await GetAuthorizedReportAsync(organizationId, reportId);
Expand Down Expand Up @@ -443,6 +447,7 @@ await _cache.RemoveByTagAsync(
/// <returns>A <see cref="FileStreamResult"/> containing the report file with content type application/octet-stream.</returns>
[SelfHosted(SelfHostedOnly = true)]
[HttpGet("{organizationId}/{reportId}/file/download")]
[RequireOrganizationAbility(nameof(OrganizationAbility.UseRiskInsights))]
public async Task<IActionResult> DownloadReportFileAsync(Guid organizationId, Guid reportId)
{
var report = await GetAuthorizedReportAsync(organizationId, reportId);
Expand Down Expand Up @@ -473,12 +478,6 @@ private async Task AuthorizeAsync(Guid organizationId)
{
throw new NotFoundException();
}

var orgAbility = await _organizationAbilityCacheService.GetOrganizationAbilityAsync(organizationId);
if (orgAbility is null || !orgAbility.UseRiskInsights)
{
throw new BadRequestException("Your organization's plan does not support this feature.");
}
}

private static void EnsureValidIds(Guid organizationId, Guid? reportId = null)
Expand Down
43 changes: 31 additions & 12 deletions src/Api/Dirt/Controllers/ReportsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
using Bit.Api.Dirt.Models.Response;
using Bit.Api.Tools.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.Context;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Reports.Models.Data;
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -26,6 +28,7 @@ public class ReportsController : Controller
private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery;
private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand;
private readonly IGetPasskeyDirectoryQuery _getPasskeyDirectoryQuery;
private readonly IOrganizationAbilityCacheService _orgAbilityCacheService;
private readonly ILogger<ReportsController> _logger;

public ReportsController(
Expand All @@ -36,6 +39,7 @@ public ReportsController(
IGetPasswordHealthReportApplicationQuery getPasswordHealthReportApplicationQuery,
IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand,
IGetPasskeyDirectoryQuery getPasskeyDirectoryQuery,
IOrganizationAbilityCacheService orgAbilityCacheService,
ILogger<ReportsController> logger
)
{
Expand All @@ -46,6 +50,7 @@ ILogger<ReportsController> logger
_getPwdHealthReportAppQuery = getPasswordHealthReportApplicationQuery;
_dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand;
_getPasskeyDirectoryQuery = getPasskeyDirectoryQuery;
_orgAbilityCacheService = orgAbilityCacheService;
_logger = logger;
}

Expand All @@ -57,10 +62,9 @@ ILogger<ReportsController> logger
/// <returns>IEnumerable of MemberCipherDetailsResponseModel</returns>
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
[HttpGet("member-cipher-details/{orgId}")]
[RequireOrganizationAbility(nameof(OrganizationAbility.UseRiskInsights))]
public async Task<IEnumerable<MemberCipherDetailsResponseModel>> GetMemberCipherDetails(Guid orgId)
{
// Using the AccessReports permission here until new permissions
// are needed for more control over reports
if (!await _currentContext.AccessReports(orgId))
{
throw new NotFoundException();
Expand All @@ -81,6 +85,7 @@ public async Task<IEnumerable<MemberCipherDetailsResponseModel>> GetMemberCipher
/// <returns>IEnumerable of MemberAccessReportResponseModel</returns>
/// <exception cref="NotFoundException">If Access reports permission is not assigned</exception>
[HttpGet("member-access/{orgId}")]
[RequireOrganizationAbility(nameof(OrganizationAbility.UseRiskInsights))]
public async Task<IEnumerable<MemberAccessDetailReportResponseModel>> GetMemberAccessReport(Guid orgId)
{
if (!await _currentContext.AccessReports(orgId))
Expand Down Expand Up @@ -123,6 +128,7 @@ private async Task<IEnumerable<RiskInsightsReportDetail>> GetRiskInsightsReportD
/// <exception cref="NotFoundException">If the user lacks access</exception>
/// <exception cref="BadRequestException">If the organization Id is not valid</exception>
[HttpGet("password-health-report-applications/{orgId}")]
[RequireOrganizationAbility(nameof(OrganizationAbility.UseRiskInsights))]
public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealthReportApplications(Guid orgId)
{
if (!await _currentContext.AccessReports(orgId))
Expand All @@ -144,10 +150,7 @@ public async Task<IEnumerable<PasswordHealthReportApplication>> GetPasswordHealt
public async Task<PasswordHealthReportApplication> AddPasswordHealthReportApplication(
[FromBody] PasswordHealthReportApplicationModel request)
{
if (!await _currentContext.AccessReports(request.OrganizationId))
{
throw new NotFoundException();
}
await AuthorizeAsync(request.OrganizationId);

var commandRequest = new AddPasswordHealthReportApplicationRequest
{
Expand All @@ -169,9 +172,9 @@ public async Task<PasswordHealthReportApplication> AddPasswordHealthReportApplic
public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealthReportApplications(
[FromBody] IEnumerable<PasswordHealthReportApplicationModel> request)
{
if (request.Any(_ => _currentContext.AccessReports(_.OrganizationId).Result == false))
foreach (var item in request)
{
throw new NotFoundException();
await AuthorizeAsync(item.OrganizationId);
}

var commandRequests = request.Select(request => new AddPasswordHealthReportApplicationRequest
Expand All @@ -197,10 +200,7 @@ public async Task<IEnumerable<PasswordHealthReportApplication>> AddPasswordHealt
public async Task DropPasswordHealthReportApplication(
[FromBody] DropPasswordHealthReportApplicationRequest request)
{
if (!await _currentContext.AccessReports(request.OrganizationId))
{
throw new NotFoundException();
}
await AuthorizeAsync(request.OrganizationId);

await _dropPwdHealthReportAppCommand.DropPasswordHealthReportApplicationAsync(request);
}
Expand All @@ -222,4 +222,23 @@ public async Task<IEnumerable<PasskeyDirectoryResponseModel>> GetPasskeyDirector
Instructions = e.Instructions
});
}

/// <summary>
/// Verifies the current Organization is authorized to access the Access Intelligence (formerly Risk Insights) reporting feature.
/// </summary>
/// <param name="organizationId">The organization ID to authorize.</param>
private async Task AuthorizeAsync(Guid organizationId)
{
if (!await _currentContext.AccessReports(organizationId))
{
throw new NotFoundException();
}

// still required since the RequireOrganizationAbilityAttribute can not be applied to all endpoints in this controller - the organizationId is not present in route.
var orgAbility = await _orgAbilityCacheService.GetOrganizationAbilityAsync(organizationId);
if (orgAbility == null || !orgAbility.UseRiskInsights)
{
throw new NotFoundException("The user's organization does not have access to this feature in their plan.");
}
}
}
61 changes: 61 additions & 0 deletions src/Api/Utilities/RequireOrganizationAbilityAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
ο»Ώusing System.Reflection;
using Bit.Api.AdminConsole.Authorization;
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Microsoft.AspNetCore.Mvc.Filters;

namespace Bit.Core.Utilities;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ DEBT: New file lives in src/Api/Utilities but declares namespace Bit.Core.Utilities.

Details and fix

Every other file in src/Api/Utilities uses namespace Bit.Api.Utilities. This looks like a carry-over from src/Core/Utilities/RequireFeatureAttribute.cs, which this class is modeled on.

The type cannot actually live in Core β€” it depends on Bit.Api.AdminConsole.Authorization.HttpContextExtensions.GetOrganizationId() β€” so the namespace advertises availability from the Core assembly that does not exist, and Core-layer code that has using Bit.Core.Utilities; will not resolve it.

namespace Bit.Api.Utilities;

OrganizationReportsController already has using Bit.Api.Utilities;; ReportsController and test/Api.Test/Utilities/RequireOrganizationAbilityAttributeTests.cs would need the using swapped.


// <summary>
/// Specifies that the class or method that this attribute is applied to requires the specified organization ability
/// to be enabled. If the organization ability is not enabled, a <see cref="FeatureUnavailableException"/> is thrown
// </summary>
Comment on lines +10 to +13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎨 SUGGESTED: The class summary uses // instead of ///, so it never renders, and it names the wrong exception.

Details and fix

Lines 10 and 13 open/close with //, which means the XML doc for the type is dropped entirely. The text also says FeatureUnavailableException (copied from RequireFeatureAttribute) while this attribute throws BadRequestException. There is also a stray // </summary> at line 22 inside the constructor docs.

/// <summary>
/// Specifies that the class or method that this attribute is applied to requires the specified organization ability
/// to be enabled. If the organization ability is not enabled, a <see cref="BadRequestException"/> is thrown.
/// </summary>

Worth fixing on a new shared utility, since consumers will rely on the documented exception type.

public class RequireOrganizationAbilityAttribute : Attribute, IAsyncActionFilter
{
private readonly PropertyInfo _ability;

/// <summary>
/// Initializes a new instance of the <see cref="RequireOrganizationAbilityAttribute"/> class with the specified ability key.
/// </summary>
/// <param name="abilityKey">The name of the organization ability to require. Should be a valid boolean property on the <see cref="OrganizationAbility"/> class.</param>
// </summary>
public RequireOrganizationAbilityAttribute(string abilityKey)
{
if (string.IsNullOrWhiteSpace(abilityKey) || !typeof(OrganizationAbility).GetProperties().Any(p => p.Name == abilityKey && p.PropertyType == typeof(bool)))
{
throw new ArgumentException("Ability key must be a valid boolean property on the OrganizationAbility class.", nameof(abilityKey));
}

_ability = typeof(OrganizationAbility).GetProperty(abilityKey)!;
}

public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
await OnActionExecutingAsync(context);
await next();
}

private async Task OnActionExecutingAsync(ActionExecutingContext context)
{
var orgId = context.HttpContext.GetOrganizationId();
if (orgId == Guid.Empty)
{
throw new Exception("Route parameter 'orgId' or 'organizationId' is missing or invalid.");
}
Comment on lines +42 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ IMPORTANT: A caller-supplied empty GUID in the route now produces a 500 instead of a 400.

Details and fix

GetOrganizationId() parses 00000000-0000-0000-0000-000000000000 successfully, so GET reports/organizations/00000000-0000-0000-0000-000000000000/latest reaches this branch. A bare Exception falls into the else arm of ExceptionHandlerFilterAttribute, which logs LogError(0, exception, "Unhandled exception") and returns 500.

Before this PR, EnsureValidIds handled the same input with BadRequestException("OrganizationId is required.") β†’ 400. This is client-triggerable, so it will also add noise to error monitoring.

if (orgId == Guid.Empty)
{
    throw new BadRequestException("Route parameter 'orgId' or 'organizationId' is missing or invalid.");
}


var orgAbilityCacheService = context.HttpContext.RequestServices.GetRequiredService<IOrganizationAbilityCacheService>();

var orgAbility = await orgAbilityCacheService.GetOrganizationAbilityAsync(orgId);
if (orgAbility == null)
{
throw new BadRequestException("The user's organization does not have access to this feature in their plan.");
}

var hasAbility = (bool)_ability.GetValue(orgAbility)!;
if (!hasAbility)
{
throw new BadRequestException("The user's organization does not have access to this feature in their plan.");
}
Comment on lines +47 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ IMPORTANT: The ability check now runs before the membership check, turning these endpoints into a cross-org plan oracle.

Details and fix

[Authorize("Application")] only requires an authenticated user β€” it does not establish membership in {orgId}. Because this is an action filter, the ability lookup happens before the action body calls _currentContext.AccessReports(orgId).

For a caller who is not a member of the target org:

Target org state Before this PR After this PR
UseRiskInsights = true 404 404
UseRiskInsights = false / not in cache 404 400 "The user's organization does not have access to this feature in their plan."

So any authenticated user holding an org GUID can now distinguish "org has Risk Insights" from "org does not / does not exist" on e.g. GET reports/organizations/{organizationId}/latest. Previously AuthorizeAsync ran AccessReports first and returned 404 uniformly.

Note that ReportsController.AuthorizeAsync (line 230) does this correctly β€” permission check first, and it throws NotFoundException for the ability failure β€” so the two halves of this feature now behave differently for the same condition.

Options:

  • Have the attribute resolve org access (e.g. ICurrentContext) before the ability lookup and throw NotFoundException when the caller has no relationship to the org, or
  • Throw NotFoundException here so the response is indistinguishable, matching ReportsController.AuthorizeAsync.

}
}
34 changes: 2 additions & 32 deletions test/Api.Test/Dirt/OrganizationReportsControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using Bit.Api.Dirt.Models.Request;
using Bit.Api.Dirt.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.Context;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Models.Data;
Expand All @@ -12,7 +11,6 @@
using Bit.Core.Dirt.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
Expand Down Expand Up @@ -226,28 +224,8 @@ await sutProvider.GetDependency<IGetOrganizationReportQuery>()
.GetLatestOrganizationReportAsync(Arg.Any<Guid>());
}

[Theory, BitAutoData]
public async Task GetLatestOrganizationReportAsync_NoUseRiskInsights_ThrowsBadRequestException(
SutProvider<OrganizationReportsController> sutProvider,
Guid orgId)
{
// Arrange
sutProvider.GetDependency<ICurrentContext>()
.AccessReports(orgId)
.Returns(true);

sutProvider.GetDependency<IOrganizationAbilityCacheService>()
.GetOrganizationAbilityAsync(orgId)
.Returns(new OrganizationAbility { UseRiskInsights = false });

// Act & Assert
await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.GetLatestOrganizationReportAsync(orgId));

await sutProvider.GetDependency<IGetOrganizationReportQuery>()
.DidNotReceive()
.GetLatestOrganizationReportAsync(Arg.Any<Guid>());
}
// The UseRiskInsights plan check is enforced by RequireOrganizationAbilityAttribute on each
// action rather than in the controller body; see RequireOrganizationAbilityAttributeTests.

// CreateOrganizationReportAsync - the file path is gated on the new architecture, then selected by
// request shape (FileSize); it is never selected by the file-storage flag.
Expand Down Expand Up @@ -1720,10 +1698,6 @@ private static void SetupAuthorization(
sutProvider.GetDependency<ICurrentContext>()
.AccessReports(orgId)
.Returns(true);

sutProvider.GetDependency<IOrganizationAbilityCacheService>()
.GetOrganizationAbilityAsync(orgId)
.Returns(new OrganizationAbility { UseRiskInsights = true });
}

private static void SetupNewArchitecture(
Expand All @@ -1746,9 +1720,5 @@ private static void SetupNewArchAuthorization(
sutProvider.GetDependency<ICurrentContext>()
.AccessReports(orgId)
.Returns(true);

sutProvider.GetDependency<IOrganizationAbilityCacheService>()
.GetOrganizationAbilityAsync(orgId)
.Returns(new OrganizationAbility { UseRiskInsights = true });
}
}
Loading
Loading