Skip to content
Merged
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
@@ -0,0 +1,15 @@
ο»Ώusing Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Context;

namespace Bit.Api.AdminConsole.Authorization;

public class GetActingUserForOrganizationQuery(ICurrentContext currentContext) : IGetActingUserForOrganizationQuery
{
public async Task<IActingUser> GetActingUserAsync(Guid userId, Guid organizationId)
{
var membership = currentContext.GetOrganization(organizationId);
var isProvider = await currentContext.ProviderUserForOrgAsync(organizationId);

return new StandardUser(userId, isProvider, membership?.Type, membership?.Permissions);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
ο»Ώusing Bit.Core.AdminConsole.Models.Data;

namespace Bit.Api.AdminConsole.Authorization;

public interface IGetActingUserForOrganizationQuery
{
/// <summary>
/// Resolves the caller into the <see cref="StandardUser"/> that represents their role in a given organization.
/// When they are a member, their role and permissions are read from the current context. When they manage the
/// organization through a linked provider, a <see cref="StandardUser"/> flagged as a provider is returned.
/// </summary>
/// <exception cref="Bit.Core.Exceptions.NotFoundException">
/// Thrown when the caller is neither a member of the organization nor manages it through a linked provider.
/// </exception>
Task<IActingUser> GetActingUserAsync(Guid userId, Guid organizationId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly IConfirmOrganizationInviteLinkCommand _confirmOrganizationInviteLinkCommand;
private readonly IGetOrganizationInviteCommand _getOrganizationInviteCommand;
private readonly V2_UpdateUserCommand.IUpdateOrganizationUserCommand _updateOrganizationUserCommandVNext;
private readonly IGetActingUserForOrganizationQuery _getActingUserForOrganizationQuery;
private readonly IGlobalSettings _globalSettings;

public OrganizationUsersController(IOrganizationRepository organizationRepository,
Expand Down Expand Up @@ -132,6 +133,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
IConfirmOrganizationInviteLinkCommand confirmOrganizationInviteLinkCommand,
IGetOrganizationInviteCommand getOrganizationInviteCommand,
V2_UpdateUserCommand.IUpdateOrganizationUserCommand updateOrganizationUserCommandVNext,
IGetActingUserForOrganizationQuery getActingUserForOrganizationQuery,
IGlobalSettings globalSettings)
{
_organizationRepository = organizationRepository;
Expand Down Expand Up @@ -169,6 +171,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
_confirmOrganizationInviteLinkCommand = confirmOrganizationInviteLinkCommand;
_getOrganizationInviteCommand = getOrganizationInviteCommand;
_updateOrganizationUserCommandVNext = updateOrganizationUserCommandVNext;
_getActingUserForOrganizationQuery = getActingUserForOrganizationQuery;
_globalSettings = globalSettings;
}

Expand Down Expand Up @@ -450,8 +453,6 @@ public async Task<IResult> Put([BindOrganization] Organization organization, Gui

var collectionAccessToSave = await GetAuthorizedCollectionsToSaveAsync(model, currentAccess, editingSelf, organization);

var actingContext = _currentContext.GetOrganization(organization.Id);

var request = new V2_UpdateUserCommand.UpdateOrganizationUserRequest(
organizationUser,
organization,
Expand All @@ -464,11 +465,7 @@ public async Task<IResult> Put([BindOrganization] Organization organization, Gui
model.Email,
model.Name,
model.DefaultUserCollectionName,
new StandardUser(
userId,
await _currentContext.OrganizationOwner(organization.Id),
actingContext?.Type,
actingContext?.Permissions));
await _getActingUserForOrganizationQuery.GetActingUserAsync(userId, organization.Id));

var result = await _updateOrganizationUserCommandVNext.UpdateUserAsync(request);
return Handle(result);
Expand Down
4 changes: 4 additions & 0 deletions src/Api/Utilities/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Bit.SharedWeb.Swagger;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.OpenApi;

namespace Bit.Api.Utilities;
Expand Down Expand Up @@ -117,5 +118,8 @@ public static void AddAuthorizationHandlers(this IServiceCollection services)

// Admin Console authorization handlers
services.AddAdminConsoleAuthorizationHandlers();

// Admin Console ActingUserQuery
services.TryAddScoped<IGetActingUserForOrganizationQuery, GetActingUserForOrganizationQuery>();
}
}
1 change: 1 addition & 0 deletions src/Core/AdminConsole/Models/Data/IActingUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace Bit.Core.AdminConsole.Models.Data;
public interface IActingUser
{
Guid? UserId { get; }
[Obsolete("This property is obsolete. Use isProvider or the OrganizationUserType where available instead.")]
bool IsOrganizationOwnerOrProvider { get; }
EventSystemUser? SystemUserType { get; }
}
8 changes: 4 additions & 4 deletions src/Core/AdminConsole/Models/Data/StandardUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@

namespace Bit.Core.AdminConsole.Models.Data;

public class StandardUser(Guid userId, bool isOrganizationOwner, OrganizationUserType? orgUserType = null,
Permissions? permissions = null) : IActingUser
public class StandardUser(Guid userId, bool isProvider, OrganizationUserType? orgUserType = null, Permissions? permissions = null) : IActingUser
{
public Guid? UserId { get; } = userId;
public bool IsOrganizationOwnerOrProvider { get; } = isOrganizationOwner;
[Obsolete("This property is obsolete. Use isProvider or the OrganizationUserType.")]
public bool IsOrganizationOwnerOrProvider => OrganizationUserType is Core.Enums.OrganizationUserType.Owner || IsProvider;
public OrganizationUserType? OrganizationUserType { get; } = orgUserType;
public Permissions? Permissions { get; } = permissions;
public EventSystemUser? SystemUserType => throw new Exception($"{nameof(StandardUser)} does not have a {nameof(SystemUserType)}");

public bool IsProvider { get; } = isProvider;
}
2 changes: 1 addition & 1 deletion src/Core/AdminConsole/Models/Data/SystemUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace Bit.Core.AdminConsole.Models.Data;
public class SystemUser(EventSystemUser systemUser) : IActingUser
{
public Guid? UserId => throw new Exception($"{nameof(SystemUserType)} does not have a {nameof(UserId)}.");

[Obsolete("This property is obsolete.")]
public bool IsOrganizationOwnerOrProvider => false;
public EventSystemUser? SystemUserType { get; } = systemUser;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ public record CustomUsersCannotManageAdminsOrOwners() : BadRequestError("Custom
public record CustomUsersCanOnlyGrantOwnPermissions() : BadRequestError("Custom users can only grant the same custom permissions that they have.");

public record CannotBeAdminOfMultipleFreeOrganizations() : BadRequestError("User can only be an admin of 1 free organization vault.");

public record ActingUserMustBeMemberOrProvider() : BadRequestError("StandardUser must be organization member or managing provider member.");
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,41 @@ public interface IOrganizationUserValidationService
/// <summary>
/// Checks whether the acting user can manage the target user without escalating privileges:
/// <list type="bullet">
/// <item>Owners and provider users can manage anyone.</item>
/// <item>Owners can manage anyone.</item>
/// <item>Admins can manage anyone except Owners.</item>
/// <item>Custom users with ManageUsers can manage Users and other Custom users.</item>
/// <item>Everyone else has no authority.</item>
/// </list>
/// Pair with an <c>AuthorizeAttribute</c> for the standard RBAC check on the endpoint.
/// Pair with an <c>AuthorizeAttribute</c> for the standard RBAC check on the endpoint. Provider users hold
/// authority above the organization role hierarchy and are not evaluated here.
/// </summary>
/// <remarks>
/// Owners are allowed to manage provider users. If your operation affects the provider user as a provider user
/// (e.g. password reset, where account takeover would enable escalation) you may not want to allow this.
/// </remarks>
/// <param name="actingUserId">The acting user's id, used to resolve provider authority.</param>
/// <param name="actingUser">The acting user's role, or <c>null</c> if not a confirmed member.</param>
/// <param name="targetUser">The member being managed.</param>
/// <returns><c>null</c> when allowed, otherwise the error explaining why.</returns>
Task<Error?> CanManageAsync(Guid actingUserId, IOrganizationUserRole? actingUser, IOrganizationUserRole targetUser);
Error? CanManage(IOrganizationUserRole? actingUser, IOrganizationUserRole targetUser);

/// <summary>
/// Checks whether the acting user can change the target member's role without escalating privileges. The acting
/// user must be able to manage both the target's current and requested role, and a Custom user may only grant
/// custom permissions they hold themselves.
/// </summary>
/// <param name="actingUserId">The acting user's id, used to resolve provider authority.</param>
/// <param name="actingUser">The acting user's role.</param>
/// <param name="actingUser">The acting user.</param>
/// <param name="targetUser">The member being managed, with their current role.</param>
/// <param name="newTargetUser">The updated member being managed (desired role and permissions).</param>
/// <returns><c>null</c> when allowed, otherwise the error describing the denial.</returns>
Task<Error?> CanManageRoleChangeAsync(Guid actingUserId, IOrganizationUserRole actingUser, IOrganizationUserRole targetUser,
/// <returns><c>null</c> when allowed, otherwise the error describing why.</returns>
Error? CanManageRoleChange(IOrganizationUserRole actingUser, IOrganizationUserRole targetUser,
IOrganizationUserRole newTargetUser);

/// <summary>
/// Checks whether the acting user can change the target member's role without escalating privileges. The acting
/// user must be able to manage both the target's current and requested role, and a Custom user may only grant
/// custom permissions they hold themselves.
/// </summary>
/// <param name="performedBy">The caller acting on the member.</param>
/// <param name="targetUser">The member being managed, with their current role.</param>
/// <param name="newTargetUser">The updated member being managed (desired role and permissions).</param>
/// <returns><c>null</c> when allowed, otherwise the error describing why.</returns>
Error? CanManageRoleChange(IActingUser performedBy, IOrganizationUserRole targetUser,
IOrganizationUserRole newTargetUser);

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
ο»Ώusing Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Repositories;
ο»Ώusing Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
Expand All @@ -11,34 +10,45 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Organizat

/// <inheritdoc />
public class OrganizationUserValidationService(
IProviderUserRepository providerUserRepository,
IOrganizationUserRepository organizationUserRepository) : IOrganizationUserValidationService
{
public async Task<Error?> CanManageAsync(Guid actingUserId, IOrganizationUserRole? actingUser, IOrganizationUserRole targetUser)
{
if (IsAuthorizedByRole(actingUser, targetUser.Type) || await IsProviderAsync(actingUserId, targetUser.OrganizationId))
{
return null;
}
public Error? CanManage(IOrganizationUserRole? actingUser, IOrganizationUserRole targetUser) =>
IsAuthorizedByRole(actingUser, targetUser.Type) ? null : CannotManageError(targetUser.Type);

return CannotManageError(targetUser.Type);
}

public async Task<Error?> CanManageRoleChangeAsync(Guid actingUserId, IOrganizationUserRole actingUser,
IOrganizationUserRole targetUser, IOrganizationUserRole newTargetUser)
public Error? CanManageRoleChange(IOrganizationUserRole actingUser, IOrganizationUserRole targetUser, IOrganizationUserRole newTargetUser)
{
// Must be able to manage both the current and requested role.
var authorizedByRole = IsAuthorizedByRole(actingUser, targetUser.Type)
&& IsAuthorizedByRole(actingUser, newTargetUser.Type);

if (!authorizedByRole && !await IsProviderAsync(actingUserId, targetUser.OrganizationId))
return authorizedByRole
? ValidateCustomPermissionsGrant(actingUser, newTargetUser)
: CannotManageError(targetUser.Type, newTargetUser.Type);
}

public Error? CanManageRoleChange(IActingUser performedBy, IOrganizationUserRole targetUser, IOrganizationUserRole newTargetUser)
{
// SystemUsers exist outside the organization hierarchy.
if (performedBy is not StandardUser standardUser)
{
return CannotManageError(targetUser.Type, newTargetUser.Type);
return null;
}

return ValidateCustomPermissionsGrant(actingUser, newTargetUser);
return GetActingUser(standardUser, targetUser.OrganizationId)
.Match(
error => error,
role => CanManageRoleChange(role, targetUser, newTargetUser));
}

private static CommandResult<OrganizationUserRole> GetActingUser(StandardUser standardUser, Guid organizationId) =>
standardUser switch
{
// Providers can act as owners when managing organization members
{ IsProvider: true } => new OrganizationUserRole(OrganizationUserType.Owner, organizationId),
{ OrganizationUserType: not null } => new OrganizationUserRole(standardUser.OrganizationUserType.Value, organizationId, standardUser.Permissions),
_ => new ActingUserMustBeMemberOrProvider()
};

public async Task<Error?> ValidateFreeOrgAdminLimitAsync(Guid? userId, PlanType planType,
OrganizationUserType currentUserType, OrganizationUserType newUserType)
{
Expand Down Expand Up @@ -93,9 +103,4 @@ private static bool IsAuthorizedByRole(IOrganizationUserRole? actingUser, Organi
targetType is OrganizationUserType.User or OrganizationUserType.Custom,
_ => false
};

// Provider users aren't org members but hold Owner-level authority.
private async Task<bool> IsProviderAsync(Guid actingUserId, Guid organizationId) =>
(await providerUserRepository.GetManyOrganizationDetailsByUserAsync(actingUserId, ProviderUserStatusType.Confirmed))
.Any(po => po.OrganizationId == organizationId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@ public async Task<ValidationResult<UpdateOrganizationUserRequest>> ValidateAsync
}
}

var roleChangeError = await ValidateRoleChangeAsync(request);
var roleChangeError = organizationUserValidationService.CanManageRoleChange(
request.PerformedBy,
request.OrganizationUserToUpdate,
new OrganizationUserRole(
request.NewType,
request.Organization.Id,
request.NewPermissions));

if (roleChangeError is not null)
{
return Invalid(request, roleChangeError);
Expand Down Expand Up @@ -178,34 +185,6 @@ public async Task<ValidationResult<UpdateOrganizationUserRequest>> ValidateAsync
: new EmailTakenOutsideOrganizationError();
}

/// <summary>
/// Delegates the role-change authority decision to
/// <see cref="IOrganizationUserValidationService.CanManageRoleChangeAsync"/>. System users skip the check.
/// </summary>
private async Task<Error?> ValidateRoleChangeAsync(UpdateOrganizationUserRequest request)
{
if (request.PerformedBy is not StandardUser standardUser)
{
return null;
}

var actingUser = new OrganizationUserRole(
standardUser.OrganizationUserType!.Value,
request.OrganizationUserToUpdate.OrganizationId,
standardUser.Permissions);

var newTargetUser = new OrganizationUserRole(
request.NewType,
request.OrganizationUserToUpdate.OrganizationId,
request.NewPermissions);

return await organizationUserValidationService.CanManageRoleChangeAsync(
standardUser.UserId!.Value,
actingUser,
request.OrganizationUserToUpdate,
newTargetUser);
}

private static bool CollectionsAreValid(List<CollectionAccessSelection> collectionAccessToSave,
ICollection<Collection> collectionsToSave, Guid organizationId)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Request;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
Expand Down Expand Up @@ -577,6 +578,40 @@ public async Task Put_WhenChangingEmailToAddressTakenOutsideOrganization_Returns
await AssertValidationProblemAsync(response, new EmailTakenOutsideOrganizationError());
}

[Fact]
public async Task Put_AsProviderUserForOrganization_PersistsChanges()
{
// A provider user with no organization membership is authorized to manage the org's users
// because their provider is linked to the organization (ManageUsersRequirement falls through
// to the provider check).
var provider = await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync(
_factory, _organization.Id, ProviderType.Msp);

var providerUserEmail = $"provider-user-{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(providerUserEmail);
await ProviderTestHelpers.CreateProviderUserAsync(_factory, provider.Id, providerUserEmail,
ProviderUserType.ProviderAdmin);

var (_, member) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id,
OrganizationUserType.User);

await _loginHelper.LoginAsync(providerUserEmail);

var request = new OrganizationUserUpdateRequestModel
{
Type = OrganizationUserType.Admin,
Permissions = new Permissions(),
Collections = [],
Groups = []
};
var response = await _client.PutAsJsonAsync($"organizations/{_organization.Id}/users/{member.Id}", request);

Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
var updatedOrgUser = await _factory.GetService<IOrganizationUserRepository>().GetByIdAsync(member.Id);
Assert.NotNull(updatedOrgUser);
Assert.Equal(OrganizationUserType.Admin, updatedOrgUser.Type);
}

private async Task SetAllowAdminAccessToAllCollectionItemsAsync(bool value)
{
_organization.AllowAdminAccessToAllCollectionItems = value;
Expand Down
Loading
Loading