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
121 changes: 93 additions & 28 deletions src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
using Bit.Core.Exceptions;
using Bit.Core.Models.Api;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
Expand All @@ -49,6 +50,7 @@
using Microsoft.AspNetCore.Mvc;
using V1_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1.IRevokeOrganizationUserCommand;
using V2_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
using V2_UpdateUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2;

namespace Bit.Api.AdminConsole.Controllers;

Expand Down Expand Up @@ -90,6 +92,8 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly ISelfRevokeOrganizationUserCommand _selfRevokeOrganizationUserCommand;
private readonly IUpdateUserResetPasswordEnrollmentCommand _updateUserResetPasswordEnrollmentCommand;
private readonly IAcceptOrganizationInviteLinkCommand _acceptOrganizationInviteLinkCommand;
private readonly IFeatureService _featureService;
private readonly V2_UpdateUserCommand.IUpdateOrganizationUserCommand _updateOrganizationUserCommandVNext;

public OrganizationUsersController(IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
Expand Down Expand Up @@ -124,7 +128,9 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
ISelfRevokeOrganizationUserCommand selfRevokeOrganizationUserCommand,
IUpdateUserResetPasswordEnrollmentCommand updateUserResetPasswordEnrollmentCommand,
IGetPendingAutoConfirmUsersQuery getPendingAutoConfirmUsersQuery,
IAcceptOrganizationInviteLinkCommand acceptOrganizationInviteLinkCommand)
IAcceptOrganizationInviteLinkCommand acceptOrganizationInviteLinkCommand,
IFeatureService featureService,
V2_UpdateUserCommand.IUpdateOrganizationUserCommand updateOrganizationUserCommandVNext)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
Expand Down Expand Up @@ -160,6 +166,8 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
_selfRevokeOrganizationUserCommand = selfRevokeOrganizationUserCommand;
_updateUserResetPasswordEnrollmentCommand = updateUserResetPasswordEnrollmentCommand;
_acceptOrganizationInviteLinkCommand = acceptOrganizationInviteLinkCommand;
_featureService = featureService;
_updateOrganizationUserCommandVNext = updateOrganizationUserCommandVNext;
}

[HttpGet("{id}")]
Expand Down Expand Up @@ -393,10 +401,13 @@ public async Task<ListResponseModel<OrganizationUserPublicKeyResponseModel>> Use

[HttpPut("{id}")]
[Authorize<ManageUsersRequirement>]
public async Task Put(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model)
public async Task<IResult> Put([BindOrganization] Organization organization, Guid id, [FromBody] OrganizationUserUpdateRequestModel model)
{
var (organizationUser, currentAccess) = await _organizationUserRepository.GetByIdWithCollectionsAsync(id);
if (organizationUser == null || organizationUser.OrganizationId != orgId)

var organizationAbility = await _organizationAbilityCacheService.GetOrganizationAbilityAsync(organization.Id);

if (organizationUser == null || organizationUser.OrganizationId != organization.Id || organizationAbility == null)
{
throw new NotFoundException();
}
Expand All @@ -407,72 +418,126 @@ public async Task Put(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequ
// Authorization check:
// If admins are not allowed access to all collections, you cannot add yourself to a group.
// No error is thrown for this, we just don't update groups.
var organizationAbility = await _organizationAbilityCacheService.GetOrganizationAbilityAsync(orgId);
var groupsToSave = editingSelf && !organizationAbility.AllowAdminAccessToAllCollectionItems
? null
: model.Groups;

var currentAccessIds = currentAccess.Select(c => c.Id).ToHashSet();
var existingUserType = organizationUser.Type;

if (_featureService.IsEnabled(FeatureFlagKeys.ChangeMemberEmailNoMp))
{
var (collectionsToSave, postedCollections) = await GetAuthorizedCollectionsToSaveAsync(model, currentAccess);

// The acting user's own membership drives the role-escalation check. Null when the caller is not
// an organization member (e.g. a provider), whose authority comes from the owner/provider flag.
var actingContext = _currentContext.GetOrganization(organization.Id);
OrganizationUser savingOrganizationUser = null;
if (actingContext != null)
{
savingOrganizationUser = new OrganizationUser { Type = actingContext.Type };
savingOrganizationUser.SetPermissions(actingContext.Permissions);
}

var request = new V2_UpdateUserCommand.UpdateOrganizationUserRequest(
organizationUser,
organization,
organizationAbility,
model.Type.Value,
model.Permissions,
model.AccessSecretsManager,
new StandardUser(userId, await _currentContext.OrganizationOwner(organization.Id)),
savingOrganizationUser,
collectionsToSave,
groupsToSave,
currentAccessIds,
postedCollections);

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

// Authorization check:
// If admins are not allowed access to all collections, you cannot add yourself to collections.
// This is not caught by the requirement below that you can ModifyUserAccess and must be checked separately
var currentAccessIds = currentAccess.Select(c => c.Id).ToHashSet();
if (editingSelf &&
!organizationAbility.AllowAdminAccessToAllCollectionItems &&
model.Collections.Any(c => !currentAccessIds.Contains(c.Id)))
{
throw new BadRequestException("You cannot add yourself to a collection.");
}

var (collectionsToSaveV1, _) = await GetAuthorizedCollectionsToSaveAsync(model, currentAccess);

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

return TypedResults.Ok();
}

/// <summary>
/// Resolves the collection access to persist for an organization user update. Collections the saving user
/// cannot <see cref="BulkCollectionOperations.ModifyUserAccess"/> are validated and the user's current
/// access to them is preserved so it is not accidentally overwritten.
/// </summary>
private async Task<(List<CollectionAccessSelection> CollectionsToSave, ICollection<Collection> Collections)> GetAuthorizedCollectionsToSaveAsync(OrganizationUserUpdateRequestModel model, ICollection<CollectionAccessSelection> currentAccess)
{
// Authorization check:
// You must have authorization to ModifyUserAccess for all collections being saved
var postedCollections = await _collectionRepository
.GetManyByManyIdsAsync(model.Collections.Select(c => c.Id));
foreach (var collection in postedCollections)
{
if (!(await _authorizationService.AuthorizeAsync(User, collection,
BulkCollectionOperations.ModifyUserAccess))
.Succeeded)
{
throw new NotFoundException();
}
// You must have authorization to ModifyUserAccess for all collections being saved. The bulk handler
// authorizes the whole set in a single call (it succeeds only if every collection is authorized).
var postedCollections = await _collectionRepository.GetManyByManyIdsAsync(model.Collections.Select(c => c.Id));
if (postedCollections.Count != 0 &&
!(await _authorizationService.AuthorizeAsync(User, postedCollections, BulkCollectionOperations.ModifyUserAccess)).Succeeded)
{
throw new NotFoundException();
}

// The client only sends collections that the saving user has permissions to edit.
// We need to combine these with collections that the user doesn't have permissions for, so that we don't
// accidentally overwrite those
var currentCollections = await _collectionRepository
.GetManyByManyIdsAsync(currentAccess.Select(cas => cas.Id));
var currentCollections = await _collectionRepository.GetManyByManyIdsAsync(currentAccess.Select(cas => cas.Id));

var readonlyCollectionIds = new HashSet<Guid>();

foreach (var collection in currentCollections)
{
if (!(await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.ModifyUserAccess))
.Succeeded)
if (!(await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.ModifyUserAccess)).Succeeded)
{
readonlyCollectionIds.Add(collection.Id);
}
}

var editedCollectionAccess = model.Collections
.Select(c => c.ToSelectionReadOnly());
// Default user collections ("My Items") are never managed through this flow. Exclude any current-access
// defaults from the preserved set so they are not re-saved; posted defaults are rejected downstream.
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));
.Where(ca => readonlyCollectionIds.Contains(ca.Id) && !defaultCollectionIds.Contains(ca.Id));
var collectionsToSave = editedCollectionAccess
.Concat(readonlyCollectionAccess)
.ToList();

var existingUserType = organizationUser.Type;
// The hydrated collections referenced by collectionsToSave (posted plus preserved read-only) so the
// validator can check existence and reject defaults without re-querying.
var collections = postedCollections
.Concat(currentCollections)
.DistinctBy(c => c.Id)
.ToList();

await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), existingUserType, userId,
collectionsToSave, groupsToSave);
return (collectionsToSave, collections);
}

[HttpPost("{id}")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
[Authorize<ManageUsersRequirement>]
public async Task PostPut(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model)
public async Task<IResult> PostPut([BindOrganization] Organization organization, Guid id, [FromBody] OrganizationUserUpdateRequestModel model)
{
await Put(orgId, id, model);
return await Put(organization, id, model);
}

[HttpPut("{userId}/reset-password-enrollment")]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2;

// Use generic "Resource not found." messages to avoid enumeration.
public record CollectionNotFound() : NotFoundError("Resource not found.");
public record GroupNotFound() : NotFoundError("Resource not found.");

public record InviteUserFirst() : BadRequestError("Invite the user first.");
public record CannotBeAdminOfMultipleFreeOrgs() : BadRequestError("User can only be an admin of one free organization.");
public record CannotAddSelfToCollection() : BadRequestError("You cannot add yourself to a collection.");
public record MustHaveConfirmedOwner() : BadRequestError("Organization must have at least one confirmed owner.");
public record OnlyOwnersCanManageOwners() : BadRequestError("Only an Owner can manage another Owner's account.");
public record CustomUsersCannotManageAdminsOrOwners() : BadRequestError("Custom users can not manage Admins or Owners.");
public record ManageMutuallyExclusive() : BadRequestError("The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true.");
public record CustomPermissionsNotEnabled() : BadRequestError("To enable custom permissions the organization must be on an Enterprise plan.");
public record CannotAssignDefaultCollection() : BadRequestError("Default collections cannot be assigned to a member.");
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Results;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2;

public interface IUpdateOrganizationUserCommand
{
Task<CommandResult> UpdateUserAsync(UpdateOrganizationUserRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Validation;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2;

public interface IUpdateOrganizationUserValidator
{
/// <summary>
/// Validates an organization user update. On success, the returned request carries the
/// collection access list with default user collections filtered out, ready to persist.
/// </summary>
Task<ValidationResult<UpdateOrganizationUserValidationRequest>> ValidateAsync(
UpdateOrganizationUserValidationRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
ο»Ώusing Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Billing.Pricing;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using OneOf.Types;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2;

public class UpdateOrganizationUserCommand(
IOrganizationUserRepository organizationUserRepository,
IUpdateOrganizationUserValidator validator,
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IPricingClient pricingClient,
IEventService eventService,
TimeProvider timeProvider)
: IUpdateOrganizationUserCommand
{
public async Task<CommandResult> UpdateUserAsync(UpdateOrganizationUserRequest request)
{
var (organizationUser, organization, organizationAbility) = (request.OrganizationUser, request.Organization, request.OrganizationAbility);

var validationResult = await validator.ValidateAsync(new UpdateOrganizationUserValidationRequest(
organizationUser,
request.Type,
request.PerformedBy,
request.PerformedByOrganizationUser,
request.CollectionsToSave?.ToList() ?? [],
request.GroupsToSave,
request.CurrentAccessIds,
organization,
organizationAbility,
request.PostedCollections));

if (validationResult.IsError)
{
return validationResult.AsError;
}

var collectionsToSave = validationResult.Request.CollectionsToSave;
var groupsToSave = validationResult.Request.GroupsToSave;

var enablingSecretsManager = !organizationUser.AccessSecretsManager && request.AccessSecretsManager;

organizationUser.Type = request.Type;
organizationUser.Permissions = CoreHelpers.ClassToJsonData(request.Permissions);
organizationUser.AccessSecretsManager = request.AccessSecretsManager;

// Only autoscale (if required) after all validation has passed so that we know it's a valid request before
// updating Stripe.
if (enablingSecretsManager)
{
var additionalSmSeatsRequired = await countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organizationUser.OrganizationId, 1);
if (additionalSmSeatsRequired > 0)
{
// TODO: https://bitwarden.atlassian.net/browse/PM-17012
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
var update = new SecretsManagerSubscriptionUpdate(organization, plan, true)
.AdjustSeats(additionalSmSeatsRequired);
await updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(update);
}
}

await organizationUserRepository.ReplaceAsync(organizationUser, collectionsToSave);

if (groupsToSave != null)
{
await organizationUserRepository.UpdateGroupsAsync(organizationUser.Id, groupsToSave, timeProvider.GetUtcNow().UtcDateTime);
}

await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Updated);

return new None();
}
}
Loading
Loading