Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
ο»Ώusing Bit.Api.AdminConsole.Authorization.Collections;
using Bit.Api.AdminConsole.Authorization.Providers;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection.Extensions;

Expand All @@ -15,7 +14,7 @@ public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection
services.TryAddEnumerable([
ServiceDescriptor.Scoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationCollectionManagementAccessHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrgUserLinkedToUserIdHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, ProviderRequirementHandler>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,21 +116,7 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext

private async Task<bool> CanCreateAsync(CurrentContextOrganization? org)
{
// Owners, Admins, and users with CreateNewCollections permission can always create collections
if (org is
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.CreateNewCollections: true })
{
return true;
}

var organizationAbility = await GetOrganizationAbilityAsync(org);

var userIsMemberOfOrg = org is not null;
var limitCollectionCreationEnabled = await GetOrganizationAbilityAsync(org) is { LimitCollectionCreation: true };
var userIsOrgOwnerOrAdmin = org is { Type: OrganizationUserType.Owner or OrganizationUserType.Admin };
// If the limit collection management setting is disabled, allow any user to create collections
if (userIsMemberOfOrg && (!limitCollectionCreationEnabled || userIsOrgOwnerOrAdmin))
if (CollectionPermissions.CanCreate(org, await GetOrganizationAbilityAsync(org)))
{
return true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
ο»Ώ#nullable enable
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations;

namespace Bit.Api.AdminConsole.Authorization.Collections;

/// <summary>
/// Reusable, pure permission checks for Collections that other authorization requirements/handlers can depend on
/// without needing to invoke <see cref="BulkCollectionAuthorizationHandler"/> directly.
/// </summary>
public static class CollectionPermissions
{
/// <summary>
/// Returns true if the user is allowed to create a new collection in the organization.
/// This does not account for Provider users - callers must check that separately (it requires a database call).
/// </summary>
public static bool CanCreate(CurrentContextOrganization? organizationClaims, OrganizationAbility? organizationAbility)
{
// Owners, Admins, and users with CreateNewCollections permission can always create collections
if (organizationClaims is
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.CreateNewCollections: true })
{
return true;
}

// If the limit collection creation setting is disabled, allow any member to create collections
return organizationClaims is not null && organizationAbility is not { LimitCollectionCreation: true };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
ο»Ώ#nullable enable

using Bit.Api.AdminConsole.Authorization.Collections;
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;

namespace Bit.Api.AdminConsole.Authorization;

/// <summary>
/// Requires that the user is allowed to create collections, has Can Manage permissions for at least one
/// collection in the organization, or has a custom permission (ManageUsers, ManageGroups, or AccessReports) that
/// depends on this same basic organization member/group information for an unrelated reason (e.g. the Members
/// page, Groups page, and Member Access Report). This data is not privileged - it contains as little information
/// as possible and no cryptographic keys or other sensitive data - but if an organization has restricted
/// collection management to a subset of users, there's no reason to expose it more broadly than the users who
/// actually have some legitimate need for it.
/// </summary>
/// <remarks>
/// This intentionally does not implement <see cref="IOrganizationRequirement"/> because it needs more than JWT
/// claims and a provider check to answer the question - it needs the Organization's ability settings and, in the
/// less common case where collection creation is restricted, a database call to check the user's collection
/// access. Pulls the organization ID from the route itself, following the same shape as
/// <see cref="OrgUserLinkedToUserIdHandler"/>.
/// </remarks>
public class OrganizationCollectionManagementAccessRequirement : IAuthorizationRequirement;

public class OrganizationCollectionManagementAccessHandler(
IHttpContextAccessor httpContextAccessor,
IUserService userService,
IProviderUserRepository providerUserRepository,
IOrganizationAbilityCacheService organizationAbilityCacheService,
ICollectionRepository collectionRepository)
: AuthorizationHandler<OrganizationCollectionManagementAccessRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OrganizationCollectionManagementAccessRequirement requirement)
{
var httpContext = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("This handler requires an HTTP context.");

var userId = userService.GetProperUserId(httpContext.User);
if (userId is null)
{
return;
}

var orgId = httpContext.GetOrganizationId();
var organizationClaims = httpContext.User.GetCurrentContextOrganization(orgId);
var organizationAbility = await organizationAbilityCacheService.GetOrganizationAbilityAsync(orgId);

if (CollectionPermissions.CanCreate(organizationClaims, organizationAbility))
{
context.Succeed(requirement);
return;
}

// Custom users who manage org members/groups or view reports have their own legitimate need for this
// basic directory data, independent of their collection permissions.
if (organizationClaims is { Type: OrganizationUserType.Custom } &&
(organizationClaims.Permissions.ManageUsers ||
organizationClaims.Permissions.ManageGroups ||
organizationClaims.Permissions.AccessReports))
{
context.Succeed(requirement);
return;
}

// The user is a confirmed member of the organization but cannot create collections - check whether they
// have Can Manage permissions on at least one collection instead.
if (organizationClaims is not null)
{
var collections = await collectionRepository.GetManySharedByOrganizationIdWithPermissionsAsync(
orgId, userId.Value, includeAccessRelationships: false);
if (collections.Any(c => c.Manage))
{
context.Succeed(requirement);
return;
}
}

// Allow provider users to access this information if they are a provider for the target organization
if (await httpContext.IsProviderUserForOrgAsync(providerUserRepository, userId.Value, orgId))
{
context.Succeed(requirement);
}
}
Comment thread
JaredScar marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion src/Api/AdminConsole/Controllers/GroupsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public async Task<GroupDetailsResponseModel> GetDetails(Guid orgId, Guid id)
}

[HttpGet("")]
[Authorize<MemberOrProviderRequirement>]
[Authorize<OrganizationCollectionManagementAccessRequirement>]
public async Task<ListResponseModel<GroupResponseModel>> GetOrganizationGroups(Guid orgId)
{
var groups = await _groupRepository.GetManyByOrganizationIdAsync(orgId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,11 @@ public async Task<OrganizationUserDetailsResponseModel> Get(Guid orgId, Guid id,
}

/// <summary>
/// Returns a set of basic information about all members of the organization. This is available to all members of
/// the organization to manage collections. For this reason, it contains as little information as possible and no
/// cryptographic keys or other sensitive data.
/// Returns a set of basic information about all members of the organization. This is available to all members
/// of the organization, since a broad range of features across the app depend on basic member lookups
/// (collection management, group management, event logs, sponsorship, etc.) that are not specific to any one
/// permission. For this reason, it contains as little information as possible and no cryptographic keys or
/// other sensitive data.

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.

Just want to confirm that this is the intended behavior. Are we keeping the permission check the same and only changing the comments?

/// </summary>
/// <param name="orgId">Organization identifier</param>
/// <returns>List of users for the organization.</returns>
Expand Down

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
ο»Ώusing System.Net;
using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Core.AdminConsole.Entities;
Expand Down Expand Up @@ -177,6 +178,97 @@ public async Task GetOrganizationGroups_NotAMember_ReturnsForbidden()
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}

[Fact]
public async Task GetOrganizationGroups_AsUserWithLimitCollectionCreationEnabledAndNoCollectionAccess_ReturnsForbidden()
{
var (email, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, _organization.Id, OrganizationUserType.User);

await EnableLimitCollectionCreationAsync();

await _loginHelper.LoginAsync(email);

var response = await _client.GetAsync($"/organizations/{_organization.Id}/groups");

Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}

[Fact]
public async Task GetOrganizationGroups_AsUserWithLimitCollectionCreationEnabledButManagesACollection_ReturnsSuccess()
{
var (email, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, _organization.Id, OrganizationUserType.User);

await OrganizationTestHelpers.CreateCollectionAsync(
_factory,
_organization.Id,
"Managed Collection",
users: [new CollectionAccessSelection { Id = orgUser.Id, ReadOnly = false, HidePasswords = false, Manage = true }]);

await EnableLimitCollectionCreationAsync();

await _loginHelper.LoginAsync(email);

var response = await _client.GetAsync($"/organizations/{_organization.Id}/groups");

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

[Theory]
[InlineData(true, false, false)]
[InlineData(false, true, false)]
[InlineData(false, false, true)]
public async Task GetOrganizationGroups_AsCustomWithManageUsersOrGroupsOrAccessReports_ReturnsSuccess(
bool manageUsers, bool manageGroups, bool accessReports)
{
var (email, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, _organization.Id, OrganizationUserType.Custom,
permissions: new Permissions
{
ManageUsers = manageUsers,
ManageGroups = manageGroups,
AccessReports = accessReports,
});

await EnableLimitCollectionCreationAsync();

await _loginHelper.LoginAsync(email);

var response = await _client.GetAsync($"/organizations/{_organization.Id}/groups");

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

[Fact]
public async Task GetOrganizationGroups_AsCustomWithUnrelatedPermissionAndNoCollectionAccess_ReturnsForbidden()
{
var (email, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, _organization.Id, OrganizationUserType.Custom,
permissions: new Permissions { AccessEventLogs = true, ManagePolicies = true });

await EnableLimitCollectionCreationAsync();

await _loginHelper.LoginAsync(email);

var response = await _client.GetAsync($"/organizations/{_organization.Id}/groups");

Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}

/// <summary>
/// Enables the "Limit collection creation" collection management setting for the test organization via the
/// public API, ensuring the OrganizationAbility cache is updated as it would be in production.
/// </summary>
private async Task EnableLimitCollectionCreationAsync()
{
await _loginHelper.LoginAsync(_ownerEmail);

var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}/collection-management",
new OrganizationCollectionManagementUpdateRequestModel { LimitCollectionCreation = true });

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

[Fact]
public async Task GetOrganizationGroupDetails_AsOwner_ReturnsSuccess()
{
Expand Down
Loading
Loading