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

Expand All @@ -25,5 +26,6 @@ public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection
// services rather than IAuthorizationHandler - see ICollectionAuthorizationService.
services.TryAddScoped<ICollectionAuthorizationService, CollectionAuthorizationService>();
services.TryAddScoped<IGroupsAuthorizationService, GroupsAuthorizationService>();
services.TryAddScoped<IOrganizationUserAuthorizationService, OrganizationUserAuthorizationService>();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ο»Ώ#nullable enable

namespace Bit.Api.AdminConsole.Authorization.OrganizationUsers;

/// <summary>
/// Decides whether the caller may update an organization user's collection access and group memberships.
/// </summary>
public interface IOrganizationUserAuthorizationService
{
Task<OrganizationUserAuthorizationResult> AuthorizeUpdateAsync(
Guid organizationId,
Guid organizationUserId,
IReadOnlyCollection<Guid> postedCollectionIds);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
ο»Ώ#nullable enable

namespace Bit.Api.AdminConsole.Authorization.OrganizationUsers;

/// <summary>
/// The outcome of an <see cref="IOrganizationUserAuthorizationService"/> check. <see cref="CanEditOwnGroups"/> is
/// deliberately not part of <see cref="IsSuccess"/> - it tells the caller whether to drop the posted group changes.
/// </summary>
public record OrganizationUserAuthorizationResult(
bool CanAddSelfToCollection,
bool CanEditOwnGroups,
IReadOnlySet<Guid> UnauthorizedPostedCollectionIds,
IReadOnlySet<Guid> ReadonlyCurrentCollectionIds)
{
public bool IsSuccess => CanAddSelfToCollection && UnauthorizedPostedCollectionIds.Count == 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
ο»Ώ#nullable enable
using Bit.Api.AdminConsole.Authorization.Collections;
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.Context;
using Bit.Core.Repositories;

namespace Bit.Api.AdminConsole.Authorization.OrganizationUsers;

public class OrganizationUserAuthorizationService(
ICurrentContext currentContext,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository,
IOrganizationAbilityCacheService organizationAbilityCacheService) : IOrganizationUserAuthorizationService
{
public async Task<OrganizationUserAuthorizationResult> AuthorizeUpdateAsync(
Guid organizationId,
Guid organizationUserId,
IReadOnlyCollection<Guid> postedCollectionIds)
{
var (organizationUser, currentAccess) = await organizationUserRepository.GetByIdWithCollectionsAsync(organizationUserId);
if (organizationUser is null || organizationUser.OrganizationId != organizationId)
{
return new OrganizationUserAuthorizationResult(false, false, new HashSet<Guid>(), new HashSet<Guid>());
}

if (!currentContext.UserId.HasValue)
{
return new OrganizationUserAuthorizationResult(false, 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 editingSelf = currentContext.UserId == organizationUser.UserId;
var currentAccessIds = currentAccess.Select(c => c.Id).ToHashSet();

// A self-editing user can't add themselves to collections they don't already have access to.
var canAddSelfToCollection = !editingSelf
|| allowAdminAccessToAllCollectionItems
|| postedCollectionIds.All(currentAccessIds.Contains);

// If admins are not allowed access to all collections, a self-editing user can't edit their own groups.
var canEditOwnGroups = !editingSelf || allowAdminAccessToAllCollectionItems;

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

var unauthorizedPostedCollectionIds = await GetUnauthorizedCollectionIdsAsync(
postedCollectionIds, organizationId, organization, allowAdminAccessToAllCollectionItems, managedCollectionIds);
var readonlyCurrentCollectionIds = await GetUnauthorizedCollectionIdsAsync(
currentAccessIds, organizationId, organization, allowAdminAccessToAllCollectionItems, managedCollectionIds);

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

return new OrganizationUserAuthorizationResult(canAddSelfToCollection, canEditOwnGroups, unauthorizedPostedCollectionIds, readonlyCurrentCollectionIds);
}

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.CanModifyUserAccess(accessDetails, organization, allowAdminAccessToAllCollectionItems, callerManagesCollection))
{
unauthorized.Add(collectionId);
}
}

return unauthorized;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Bit.Api.AdminConsole.Attributes;
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization.Collections;
using Bit.Api.AdminConsole.Authorization.OrganizationUsers;
using Bit.Api.AdminConsole.Authorization.Requirements;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
Expand Down Expand Up @@ -97,6 +98,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly Bitwarden.Server.Sdk.Features.IFeatureService _featureService;
private readonly V2_UpdateUserCommand.IUpdateOrganizationUserCommand _updateOrganizationUserCommandVNext;
private readonly IGlobalSettings _globalSettings;
private readonly IOrganizationUserAuthorizationService _organizationUserAuthorizationService;

public OrganizationUsersController(IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
Expand Down Expand Up @@ -135,7 +137,8 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
IGetOrganizationInviteCommand getOrganizationInviteCommand,
Bitwarden.Server.Sdk.Features.IFeatureService featureService,
V2_UpdateUserCommand.IUpdateOrganizationUserCommand updateOrganizationUserCommandVNext,
IGlobalSettings globalSettings)
IGlobalSettings globalSettings,
IOrganizationUserAuthorizationService organizationUserAuthorizationService)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
Expand Down Expand Up @@ -175,6 +178,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
_featureService = featureService;
_updateOrganizationUserCommandVNext = updateOrganizationUserCommandVNext;
_globalSettings = globalSettings;
_organizationUserAuthorizationService = organizationUserAuthorizationService;
}

[HttpGet("{id}")]
Expand Down Expand Up @@ -466,6 +470,96 @@ await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(or
return TypedResults.Ok();
}

/// <summary>
/// Behaves like <see cref="Put"/>, but authorizes via <see cref="IOrganizationUserAuthorizationService"/>.
/// </summary>
[HttpPatch("{id}")]
[Authorize<ManageUsersRequirement>]
[Bitwarden.Server.Sdk.Features.RequireFeature(FeatureFlagKeys.OrganizationUserAuthorizationServiceEndpoint)]
public async Task<IResult> PatchWithNewAuthorization([BindOrganization] Organization organization, Guid id, [FromBody] OrganizationUserUpdateRequestModel model)
{
var (organizationUser, currentAccess) = await _organizationUserRepository.GetByIdWithCollectionsAsync(id);

if (organizationUser == null || organizationUser.OrganizationId != organization.Id)
{
throw new NotFoundException();
}

var userId = _userService.GetProperUserId(User).Value;
var existingUserType = organizationUser.Type;

var postedCollectionIds = model.Collections.Select(c => c.Id).ToList();
var authorizationResult = await _organizationUserAuthorizationService.AuthorizeUpdateAsync(organization.Id, id, postedCollectionIds);

if (!authorizationResult.CanAddSelfToCollection)
{
throw new BadRequestException("You cannot add yourself to a collection.");
}

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

var groupsToSave = authorizationResult.CanEditOwnGroups ? model.Groups : null;
var collectionAccessToSave = await BuildCollectionAccessToSaveAsync(model, currentAccess, authorizationResult.ReadonlyCurrentCollectionIds);

if (_featureService.IsEnabled(FeatureFlagKeys.ChangeMemberEmailNoMp))
{
var actingContext = _currentContext.GetOrganization(organization.Id);

var request = new V2_UpdateUserCommand.UpdateOrganizationUserRequest(
organizationUser,
organization,
model.Type.Value,
model.Permissions,
model.AccessSecretsManager,
model.AccessPam,
collectionAccessToSave,
groupsToSave,
model.Email,
model.Name,
model.DefaultUserCollectionName,
new StandardUser(
userId,
await _currentContext.OrganizationOwner(organization.Id),
actingContext?.Type,
actingContext?.Permissions));

var result = await _updateOrganizationUserCommandVNext.UpdateUserAsync(request);
return Handle(result);
}

await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), existingUserType, userId,
collectionAccessToSave, groupsToSave, model.DefaultUserCollectionName);

return TypedResults.Ok();
}

/// <summary>
/// Merges the posted collection access with preserved readonly access, excluding default user collections.
/// </summary>
private async Task<List<CollectionAccessSelection>> BuildCollectionAccessToSaveAsync(
OrganizationUserUpdateRequestModel model, ICollection<CollectionAccessSelection> currentAccess, IReadOnlySet<Guid> readonlyCollectionIds)
{
var postedCollections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(c => c.Id));
var currentCollections = await _collectionRepository.GetManyByManyIdsAsync(currentAccess.Select(cas => cas.Id));

var defaultCollectionIds = postedCollections
.Concat(currentCollections)
.Where(c => c.Type == CollectionType.DefaultUserCollection)
.Select(c => c.Id)
.ToHashSet();

var editedCollectionAccess = model.Collections.Select(c => c.ToSelectionReadOnly());
var readonlyCollectionAccess = currentAccess.Where(ca => readonlyCollectionIds.Contains(ca.Id));

return editedCollectionAccess
.Concat(readonlyCollectionAccess)
.Where(ac => !defaultCollectionIds.Contains(ac.Id))
.ToList();
}

/// <summary>
/// Resolves the collection access to persist for an organization user update, enforcing the saving user's
/// authorization over the affected collections.
Expand Down
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ public static partial class FeatureFlagKeys
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";
public const string OrganizationUserAuthorizationServiceEndpoint = "organization-user-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,92 @@
ο»Ώusing System.Net;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using NSubstitute;
using Xunit;

namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;

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

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

public OrganizationUsersControllerPatchWithNewAuthorizationTests(ApiApplicationFactory factory)
{
_factory = factory;
_factory.SubstituteService<IPushNotificationService>(_ => { });
_factory.SubstituteService<Bit.Core.Services.IFeatureService>(_ => { });
_factory.SubstituteService<Bitwarden.Server.Sdk.Features.IFeatureService>(featureService =>
{
featureService.IsEnabled(FeatureFlagKeys.OrganizationUserAuthorizationServiceEndpoint, Arg.Any<bool>())
.Returns(true);
featureService.IsEnabled(FeatureFlagKeys.ChangeMemberEmailNoMp, Arg.Any<bool>())
.Returns(false);
});
_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 target's collections, so this is what authorizes
// them to change the target'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_UpdatesOrganizationUser_Success()
{
var (_, targetUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, _organization.Id, OrganizationUserType.User);

var model = new OrganizationUserUpdateRequestModel
{
Type = OrganizationUserType.Admin,
AccessSecretsManager = false,
AccessPam = false,
Permissions = new Permissions(),
Collections = [],
Groups = []
};

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

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

var updatedUser = await _factory.GetService<IOrganizationUserRepository>().GetByIdAsync(targetUser.Id);
Assert.NotNull(updatedUser);
Assert.Equal(OrganizationUserType.Admin, updatedUser.Type);
}
}
Loading
Loading