-
Notifications
You must be signed in to change notification settings - Fork 1.7k
[PM-38273] feat(admin-console): Add InjectOrganizationAttribute and OrganizationModelBinder #7659
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
63565ae
feat(admin-console): Add InjectOrganizationAttribute and Organizationβ¦
JaredScar 6e0458f
feat(admin-console): Introduce BindOrganizationAttribute and Organizaβ¦
JaredScar f0942db
feat(admin-console): Update GetResetPasswordDetails to use BindOrganiβ¦
JaredScar fcded9a
fix(admin-console): Correct organization ID check in GetResetPasswordβ¦
JaredScar dc6a36a
Merge branch 'main' into ac/pm-33919-inject-organization-attribute
JaredScar e255008
Refactor OrganizationUsersControllerTests to use bound organization iβ¦
JaredScar 0fdb651
Fix UTF-8 BOM issue in BindOrganizationAttribute.cs
JaredScar c2011b6
Add integration tests for OrganizationUsersController's BindOrganizatβ¦
JaredScar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
60 changes: 60 additions & 0 deletions
60
src/Api/AdminConsole/Attributes/BindOrganizationAttribute.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)); | ||
|
|
||
| /// <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); | ||
| } | ||
| } | ||
|
JaredScar marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
144 changes: 144 additions & 0 deletions
144
...egrationTest/AdminConsole/Controllers/OrganizationUsersControllerBindOrganizationTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
122 changes: 122 additions & 0 deletions
122
test/Api.Test/AdminConsole/Attributes/BindOrganizationAttributeTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| }; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.