Skip to content
Draft
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,4 +1,5 @@
ο»Ώusing Bit.Api.AdminConsole.Authorization.Collections;
using Bit.Api.AdminConsole.Authorization.Groups;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection.Extensions;

Expand All @@ -23,5 +24,6 @@ public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection
// Fine-grained, relationship-dependent authorization for collection access is handled by plain injected
// services rather than IAuthorizationHandler - see ICollectionAuthorizationService.
services.TryAddScoped<ICollectionAuthorizationService, CollectionAuthorizationService>();
services.TryAddScoped<IGroupsAuthorizationService, GroupsAuthorizationService>();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ο»Ώ#nullable enable

namespace Bit.Api.AdminConsole.Authorization.Groups;

/// <summary>
/// The outcome of an <see cref="IGroupsAuthorizationService"/> check.
/// </summary>
public record GroupsAuthorizationResult(
bool CanAddSelfToGroup,
IReadOnlySet<Guid> UnauthorizedPostedCollectionIds,
IReadOnlySet<Guid> ReadonlyCurrentCollectionIds)
{
public bool IsSuccess => CanAddSelfToGroup && UnauthorizedPostedCollectionIds.Count == 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
ο»Ώ#nullable enable
using Bit.Api.AdminConsole.Authorization.Collections;
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Repositories;

namespace Bit.Api.AdminConsole.Authorization.Groups;

public class GroupsAuthorizationService(
ICurrentContext currentContext,
IGroupRepository groupRepository,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository,
IOrganizationAbilityCacheService organizationAbilityCacheService) : IGroupsAuthorizationService
{
public async Task<GroupsAuthorizationResult> AuthorizeUpdateAsync(
Guid organizationId,
Guid groupId,
IReadOnlyCollection<Guid> postedCollectionIds,
IReadOnlyCollection<Guid> postedUserIds)
{
var (group, currentAccess) = await groupRepository.GetByIdWithCollectionsAsync(groupId);
if (group is null || group.OrganizationId != organizationId)
{
return new GroupsAuthorizationResult(false, new HashSet<Guid>(), new HashSet<Guid>());
}

if (!currentContext.UserId.HasValue)
{
return new GroupsAuthorizationResult(false, new HashSet<Guid>(), new HashSet<Guid>());
}

var organization = currentContext.GetOrganization(organizationId);
var organizationAbility = await organizationAbilityCacheService.GetOrganizationAbilityAsync(organizationId);
var allowAdminAccessToAllCollectionItems = organizationAbility is { AllowAdminAccessToAllCollectionItems: true };

var canAddSelfToGroup = await CanAddSelfToGroupAsync(organizationId, groupId, postedUserIds, allowAdminAccessToAllCollectionItems);

var managedCollectionIds = await GetManagedCollectionIdsAsync(currentContext.UserId.Value);

var unauthorizedPostedCollectionIds = await GetUnauthorizedCollectionIdsAsync(
postedCollectionIds, organizationId, organization, allowAdminAccessToAllCollectionItems, managedCollectionIds);
var readonlyCurrentCollectionIds = await GetUnauthorizedCollectionIdsAsync(
currentAccess.Select(ca => ca.Id), organizationId, organization, allowAdminAccessToAllCollectionItems, managedCollectionIds);

if ((!canAddSelfToGroup || unauthorizedPostedCollectionIds.Count > 0)
&& await currentContext.ProviderUserForOrgAsync(organizationId))
{
return new GroupsAuthorizationResult(true, new HashSet<Guid>(), new HashSet<Guid>());
}

return new GroupsAuthorizationResult(canAddSelfToGroup, unauthorizedPostedCollectionIds, readonlyCurrentCollectionIds);
}

private async Task<bool> CanAddSelfToGroupAsync(
Guid organizationId, Guid groupId, IReadOnlyCollection<Guid> postedUserIds, bool allowAdminAccessToAllCollectionItems)
{
if (allowAdminAccessToAllCollectionItems)
{
return true;
}

// The caller may be a provider rather than an organization member, in which case there's no self to add.
var callerOrganizationUser = await organizationUserRepository.GetByOrganizationAsync(organizationId, currentContext.UserId!.Value);
if (callerOrganizationUser is null)
{
return true;
}

var currentGroupUserIds = await groupRepository.GetManyUserIdsByIdAsync(groupId);
return currentGroupUserIds.Contains(callerOrganizationUser.Id) || !postedUserIds.Contains(callerOrganizationUser.Id);
}

private async Task<HashSet<Guid>> GetManagedCollectionIdsAsync(Guid userId)
{
var callerCollections = await collectionRepository.GetManyByUserIdAsync(userId);
return callerCollections.Where(c => c.Manage).Select(c => c.Id).ToHashSet();
}

private async Task<HashSet<Guid>> GetUnauthorizedCollectionIdsAsync(
IEnumerable<Guid> collectionIds,
Guid organizationId,
CurrentContextOrganization? organization,
bool allowAdminAccessToAllCollectionItems,
HashSet<Guid> managedCollectionIds)
{
var unauthorized = new HashSet<Guid>();
foreach (var collectionId in collectionIds)
{
var (collection, accessDetails) = await collectionRepository.GetByIdWithAccessAsync(collectionId);
if (collection is null || collection.OrganizationId != organizationId)
{
continue;
}

var callerManagesCollection = managedCollectionIds.Contains(collectionId);
if (!CollectionRules.CanModifyGroupAccess(accessDetails, organization, allowAdminAccessToAllCollectionItems, callerManagesCollection))
{
unauthorized.Add(collectionId);
}
}

return unauthorized;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
ο»Ώ#nullable enable

namespace Bit.Api.AdminConsole.Authorization.Groups;

/// <summary>
/// Decides whether the caller may update a group's member list and collection access.
/// </summary>
public interface IGroupsAuthorizationService
{
Task<GroupsAuthorizationResult> AuthorizeUpdateAsync(
Guid organizationId,
Guid groupId,
IReadOnlyCollection<Guid> postedCollectionIds,
IReadOnlyCollection<Guid> postedUserIds);
}
44 changes: 43 additions & 1 deletion src/Api/AdminConsole/Controllers/GroupsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@

using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization.Collections;
using Bit.Api.AdminConsole.Authorization.Groups;
using Bit.Api.AdminConsole.Authorization.Requirements;
using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.AdminConsole.Models.Response;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
Expand Down Expand Up @@ -34,6 +36,7 @@ public class GroupsController : Controller
private readonly IUserService _userService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly ICollectionRepository _collectionRepository;
private readonly IGroupsAuthorizationService _groupsAuthorizationService;

public GroupsController(
IGroupRepository groupRepository,
Expand All @@ -46,7 +49,8 @@ public GroupsController(
IOrganizationAbilityCacheService organizationAbilityCacheService,
IUserService userService,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository)
ICollectionRepository collectionRepository,
IGroupsAuthorizationService groupsAuthorizationService)
{
_groupRepository = groupRepository;
_groupService = groupService;
Expand All @@ -59,6 +63,7 @@ public GroupsController(
_userService = userService;
_organizationUserRepository = organizationUserRepository;
_collectionRepository = collectionRepository;
_groupsAuthorizationService = groupsAuthorizationService;
}

[HttpGet("{id}")]
Expand Down Expand Up @@ -221,6 +226,43 @@ public async Task<GroupResponseModel> PostPut(Guid orgId, Guid id, [FromBody] Gr
return await Put(orgId, id, model);
}

/// <summary>
/// Behaves like <see cref="Put"/>, but authorizes via <see cref="IGroupsAuthorizationService"/>.
/// </summary>
[HttpPatch("{id}")]
[Authorize<ManageGroupsRequirement>]
[Bitwarden.Server.Sdk.Features.RequireFeature(FeatureFlagKeys.GroupsAuthorizationServiceEndpoint)]
public async Task<GroupResponseModel> PatchWithNewAuthorization(Guid orgId, Guid id, [FromBody] GroupRequestModel model)
{
var (group, currentAccess) = await _groupRepository.GetByIdWithCollectionsAsync(id);
if (group == null || group.OrganizationId != orgId)
{
throw new NotFoundException();
}

var postedCollectionIds = model.Collections.Select(c => c.Id).ToList();
var authorizationResult = await _groupsAuthorizationService.AuthorizeUpdateAsync(orgId, id, postedCollectionIds, model.Users.ToList());

if (!authorizationResult.CanAddSelfToGroup)
{
throw new BadRequestException("You cannot add yourself to groups.");
}

if (authorizationResult.UnauthorizedPostedCollectionIds.Count > 0)
{
throw new NotFoundException();
}

var editedCollectionAccess = model.Collections.Select(c => c.ToSelectionReadOnly());
var readonlyCollectionAccess = currentAccess.Where(ca => authorizationResult.ReadonlyCurrentCollectionIds.Contains(ca.Id));
var collectionsToSave = editedCollectionAccess.Concat(readonlyCollectionAccess).ToList();

var organization = await _organizationRepository.GetByIdAsync(orgId);

await _updateGroupCommand.UpdateGroupAsync(model.ToGroup(group), organization, collectionsToSave, model.Users);
return new GroupResponseModel(group);
}

[HttpDelete("{id}")]
[Authorize<ManageGroupsRequirement>]
public async Task Delete(Guid orgId, Guid id)
Expand Down
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ public static partial class FeatureFlagKeys
public const string ChangeMemberEmailNoMp = "pm-28365-change-member-email-no-mp";
public const string PM34423StagedStatus = "pm-34423-staged-status";
public const string PM12473CollectionUserAccessEndpoint = "pm-12473-collection-user-access-endpoint";
public const string GroupsAuthorizationServiceEndpoint = "groups-authorization-service-endpoint";

/* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
ο»Ώusing System.Net;
using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using NSubstitute;
using Xunit;

namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;

public class GroupsControllerPatchWithNewAuthorizationTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly LoginHelper _loginHelper;

private string _ownerEmail = null!;
private Organization _organization = null!;

public GroupsControllerPatchWithNewAuthorizationTests(ApiApplicationFactory factory)
{
_factory = factory;
_factory.SubstituteService<IPushNotificationService>(_ => { });
_factory.SubstituteService<Bit.Core.Services.IFeatureService>(_ => { });
_factory.SubstituteService<Bitwarden.Server.Sdk.Features.IFeatureService>(featureService =>
featureService.IsEnabled(FeatureFlagKeys.GroupsAuthorizationServiceEndpoint, Arg.Any<bool>())
.Returns(true));
_client = factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}

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

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

// The owner has no direct Manage access on the group's collections, so this is what authorizes
// them to change the group's collection access.
_organization.AllowAdminAccessToAllCollectionItems = true;
await _factory.GetService<IOrganizationRepository>().UpsertAsync(_organization);

await _loginHelper.LoginAsync(_ownerEmail);
}

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

[Fact]
public async Task PatchWithNewAuthorization_UpdatesGroup_Success()
{
var group = await OrganizationTestHelpers.CreateGroup(_factory, _organization.Id);

var model = new GroupRequestModel
{
Name = "renamed-via-new-authorization",
Collections = [],
Users = []
};

var response = await _client.PatchAsJsonAsync(
$"organizations/{_organization.Id}/groups/{group.Id}", model);

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

var updatedGroup = await _factory.GetService<IGroupRepository>().GetByIdAsync(group.Id);
Assert.NotNull(updatedGroup);
Assert.Equal("renamed-via-new-authorization", updatedGroup.Name);
}
}
Loading
Loading