Skip to content
Merged
60 changes: 60 additions & 0 deletions src/Api/AdminConsole/Attributes/BindOrganizationAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
ο»Ώusing Bit.Api.AdminConsole.Authorization;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace Bit.Api.AdminConsole.Attributes;

/// <summary>
/// Binds an <see cref="Organization"/> parameter by loading it from the database
/// using the <c>orgId</c> or <c>organizationId</c> route parameter.
/// </summary>
/// <remarks>
/// If the organization is not found, a <see cref="NotFoundException"/> is thrown.
/// </remarks>
/// <example>
/// <code><![CDATA[
/// [HttpPost("bulk-auto-confirm")]
/// public async Task<IResult> BulkAutomaticallyConfirmOrganizationUsersAsync(
/// [BindOrganization] Organization organization,
/// [FromBody] OrganizationUserBulkConfirmRequestModel model)
/// ]]></code>
/// </example>
[AttributeUsage(AttributeTargets.Parameter)]
public sealed class BindOrganizationAttribute() : ModelBinderAttribute(typeof(OrganizationModelBinder));
Comment thread
jrmccannon marked this conversation as resolved.

/// <summary>
/// Custom model binder that loads an <see cref="Organization"/> from the database
/// using the <c>orgId</c> or <c>organizationId</c> route parameter and binds it to the parameter.
/// </summary>
/// <remarks>
/// This binder is used via the <see cref="BindOrganizationAttribute"/>.
/// </remarks>
public class OrganizationModelBinder : IModelBinder
{
public async Task BindModelAsync(ModelBindingContext bindingContext)
{
Guid orgId;
try
{
orgId = bindingContext.HttpContext.GetOrganizationId();
}
catch (InvalidOperationException)
{
throw new BadRequestException("Route parameter 'orgId' or 'organizationId' is missing or invalid.");
}

var repo = bindingContext.HttpContext.RequestServices
.GetRequiredService<IOrganizationRepository>();

var organization = await repo.GetByIdAsync(orgId);
if (organization is null)
{
throw new NotFoundException();
}

bindingContext.Result = ModelBindingResult.Success(organization);
}
}
Comment thread
JaredScar marked this conversation as resolved.
15 changes: 5 additions & 10 deletions src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
Expand Down Expand Up @@ -230,10 +231,11 @@ private ListResponseModel<OrganizationUserUserDetailsResponseModel> GetResultLis

[HttpGet("{id}/reset-password-details")]
[Authorize<ManageAccountRecoveryRequirement>]
public async Task<OrganizationUserResetPasswordDetailsResponseModel> GetResetPasswordDetails(Guid orgId, Guid id)
public async Task<OrganizationUserResetPasswordDetailsResponseModel> GetResetPasswordDetails(Guid id,
[BindOrganization] Organization organization)
Comment thread
JaredScar marked this conversation as resolved.
{
var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (organizationUser is null || organizationUser.OrganizationId != orgId || organizationUser.UserId is null)
if (organizationUser is null || organizationUser.OrganizationId != organization.Id || organizationUser.UserId is null)
{
throw new NotFoundException();
}
Expand All @@ -246,14 +248,7 @@ public async Task<OrganizationUserResetPasswordDetailsResponseModel> GetResetPas
throw new NotFoundException();
}

// Retrieve Encrypted Private Key from organization
var org = await _organizationRepository.GetByIdAsync(orgId);
if (org == null)
{
throw new NotFoundException();
}

return new OrganizationUserResetPasswordDetailsResponseModel(new OrganizationUserResetPasswordDetails(organizationUser, user, org));
return new OrganizationUserResetPasswordDetailsResponseModel(new OrganizationUserResetPasswordDetails(organizationUser, user, organization));
}

[HttpPost("account-recovery-details")]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
ο»Ώusing System.Net;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Xunit;

namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;

/// <summary>
/// Integration tests for <see cref="Bit.Api.AdminConsole.Attributes.BindOrganizationAttribute"/>,
/// exercised through the GET reset-password-details endpoint which binds an Organization from the
/// <c>orgId</c> route parameter.
/// </summary>
public class OrganizationUsersControllerBindOrganizationTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{
private readonly ApiApplicationFactory _factory;
private readonly HttpClient _client;
private readonly LoginHelper _loginHelper;

private string _ownerEmail = null!;

public OrganizationUsersControllerBindOrganizationTests(ApiApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}

public async Task InitializeAsync()
{
_ownerEmail = $"bind-org-test-{Guid.NewGuid()}@example.com";
await _factory.LoginWithNewAccount(_ownerEmail);
}

public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}

[Fact]
public async Task GetResetPasswordDetails_HappyPath_ReturnsOk()
{
// Arrange
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
plan: PlanType.EnterpriseAnnually,
ownerEmail: _ownerEmail,
passwordManagerSeats: 10,
paymentMethod: PaymentMethodType.Card);

var organizationRepository = _factory.GetService<IOrganizationRepository>();
organization.UseResetPassword = true;
await organizationRepository.ReplaceAsync(organization);

await _loginHelper.LoginAsync(_ownerEmail);

var (_, memberOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, organization.Id, OrganizationUserType.User);

var orgUserRepository = _factory.GetService<IOrganizationUserRepository>();
memberOrgUser.ResetPasswordKey = "encrypted-reset-password-key";
await orgUserRepository.ReplaceAsync(memberOrgUser);

// Act
var response = await _client.GetAsync(
$"organizations/{organization.Id}/users/{memberOrgUser.Id}/reset-password-details");

// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

await organizationRepository.DeleteAsync(organization);
}

[Fact]
public async Task GetResetPasswordDetails_OrgUserNotFound_ReturnsNotFound()
{
// Arrange β€” org exists and auth passes, but the org user ID in the path does not exist.
// BindOrganizationAttribute successfully binds the org; the endpoint then throws
// NotFoundException because the repository returns null for the unknown org user ID.
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
plan: PlanType.EnterpriseAnnually,
ownerEmail: _ownerEmail,
passwordManagerSeats: 10,
paymentMethod: PaymentMethodType.Card);

var organizationRepository = _factory.GetService<IOrganizationRepository>();
organization.UseResetPassword = true;
await organizationRepository.ReplaceAsync(organization);

await _loginHelper.LoginAsync(_ownerEmail);

// Act β€” use a random Guid that has no matching OrganizationUser row
var response = await _client.GetAsync(
$"organizations/{organization.Id}/users/{Guid.NewGuid()}/reset-password-details");

// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);

await organizationRepository.DeleteAsync(organization);
}

[Fact]
public async Task GetResetPasswordDetails_OrgUserBelongsToDifferentOrg_ReturnsNotFound()
{
// Arrange β€” create two separate organizations
var (org1, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
plan: PlanType.EnterpriseAnnually,
ownerEmail: _ownerEmail,
passwordManagerSeats: 10,
paymentMethod: PaymentMethodType.Card);

var secondOwnerEmail = $"bind-org-test-owner2-{Guid.NewGuid()}@example.com";
await _factory.LoginWithNewAccount(secondOwnerEmail);

var (org2, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
plan: PlanType.EnterpriseAnnually,
ownerEmail: secondOwnerEmail,
passwordManagerSeats: 10,
paymentMethod: PaymentMethodType.Card);

var organizationRepository = _factory.GetService<IOrganizationRepository>();
org1.UseResetPassword = true;
await organizationRepository.ReplaceAsync(org1);

// Create a user in org2
var (_, org2MemberOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, org2.Id, OrganizationUserType.User);

// Log in as owner of org1 (who has ManageAccountRecovery for org1)
await _loginHelper.LoginAsync(_ownerEmail);

// Act β€” request org1's endpoint but pass an org user ID that belongs to org2
var response = await _client.GetAsync(
$"organizations/{org1.Id}/users/{org2MemberOrgUser.Id}/reset-password-details");

// Assert β€” the org user's OrganizationId does not match org1, so NotFoundException is thrown
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);

await organizationRepository.DeleteAsync(org1);
await organizationRepository.DeleteAsync(org2);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
ο»Ώusing Bit.Api.AdminConsole.Attributes;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using Xunit;

namespace Bit.Api.Test.AdminConsole.Attributes;

public class BindOrganizationAttributeTests
{
private readonly IOrganizationRepository _organizationRepository;
private readonly Organization _organization;
private readonly Guid _orgId;

public BindOrganizationAttributeTests()
{
_organizationRepository = Substitute.For<IOrganizationRepository>();
_orgId = Guid.NewGuid();
_organization = new Organization { Id = _orgId };
}

[Fact]
public async Task BindModelAsync_OrganizationExists_BindsSuccessfully()
{
var binder = new OrganizationModelBinder();
_organizationRepository.GetByIdAsync(_orgId).Returns(_organization);

var context = CreateBindingContext();

await binder.BindModelAsync(context);

Assert.True(context.Result.IsModelSet);
Assert.Equal(_organization, context.Result.Model);
}

[Fact]
public async Task BindModelAsync_OrganizationNotFound_ThrowsNotFoundException()
{
var binder = new OrganizationModelBinder();
_organizationRepository.GetByIdAsync(_orgId).Returns((Organization)null);

var context = CreateBindingContext();

await Assert.ThrowsAsync<NotFoundException>(() => binder.BindModelAsync(context));
}

[Fact]
public async Task BindModelAsync_InvalidOrgId_ThrowsBadRequestException()
{
var binder = new OrganizationModelBinder();
var context = CreateBindingContext(orgIdRouteValue: "not-a-guid");

var exception = await Assert.ThrowsAsync<BadRequestException>(() => binder.BindModelAsync(context));
Assert.Equal("Route parameter 'orgId' or 'organizationId' is missing or invalid.", exception.Message);
}

[Fact]
public async Task BindModelAsync_MissingOrgId_ThrowsBadRequestException()
{
var binder = new OrganizationModelBinder();
var context = CreateBindingContext(includeOrgId: false);

var exception = await Assert.ThrowsAsync<BadRequestException>(() => binder.BindModelAsync(context));
Assert.Equal("Route parameter 'orgId' or 'organizationId' is missing or invalid.", exception.Message);
}

[Fact]
public async Task BindModelAsync_OrganizationIdRouteParam_ResolvesOrgId()
{
var binder = new OrganizationModelBinder();
_organizationRepository.GetByIdAsync(_orgId).Returns(_organization);

var context = CreateBindingContext(useOrganizationIdRoute: true);

await binder.BindModelAsync(context);

Assert.True(context.Result.IsModelSet);
Assert.Equal(_organization, context.Result.Model);
}

private DefaultModelBindingContext CreateBindingContext(
string orgIdRouteValue = null,
bool includeOrgId = true,
bool useOrganizationIdRoute = false)
{
var httpContext = new DefaultHttpContext();
var services = new ServiceCollection();
services.AddScoped(_ => _organizationRepository);
httpContext.RequestServices = services.BuildServiceProvider();

var routeData = new RouteData();
if (includeOrgId)
{
var key = useOrganizationIdRoute ? "organizationId" : "orgId";
routeData.Values[key] = orgIdRouteValue ?? _orgId.ToString();
}

httpContext.Request.RouteValues = routeData.Values;

var actionContext = new ActionContext(
httpContext,
routeData,
new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor(),
new ModelStateDictionary());

var metadataProvider = new EmptyModelMetadataProvider();
var metadata = metadataProvider.GetMetadataForType(typeof(Organization));

return new DefaultModelBindingContext
{
ActionContext = actionContext,
ModelMetadata = metadata,
ModelName = "organization"
};
}
}
Loading
Loading