From 81ad18962a51498ed0527bae1ae4ace0ca3cc1a6 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Fri, 26 Jun 2026 11:09:23 -0500 Subject: [PATCH] added Org User Action Validator to handle logic for org users performing actions. --- .../OrganizationUserAction/Errors.cs | 16 ++ .../OrganizationUserActionRequest.cs | 31 +++ .../OrganizationUserActionValidator.cs | 81 +++++++ .../OrganizationUserActionValidatorTests.cs | 207 ++++++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/Errors.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/OrganizationUserActionRequest.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/OrganizationUserActionValidator.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/OrganizationUserActionValidatorTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/Errors.cs new file mode 100644 index 000000000000..426db3f9f569 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/Errors.cs @@ -0,0 +1,16 @@ +using Bit.Core.AdminConsole.Utilities.v2; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationUserAction; + +/// +/// Returned when an acting user attempts to act on (or assign) an organization role that outranks their own. +/// +public record CannotManageHigherRoleError() + : BadRequestError("You cannot perform this action on a member with a higher organization role than your own."); + +/// +/// Returned when the acting user has no authority to manage members for the action — for example a regular +/// User, or a Custom user without the required permission. +/// +public record MissingManagePermissionError() + : BadRequestError("You do not have permission to manage organization members."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/OrganizationUserActionRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/OrganizationUserActionRequest.cs new file mode 100644 index 000000000000..4e344650ef87 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/OrganizationUserActionRequest.cs @@ -0,0 +1,31 @@ +using Bit.Core.Enums; +using Bit.Core.Models.Data; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationUserAction; + +/// +/// Whether an acting user may manage organization members at all, independent of any target. +/// adds a target role for the full role-escalation check. +/// +/// The acting user's role, or null if not a confirmed member (they may still be authorized via ). +/// The acting user's custom permissions. Only consulted for Custom users. +/// Picks the permission that authorizes a Custom user for this action (e.g. p => p.ManageUsers). Only consulted for Custom users. +/// Whether the acting user is a provider user for the org, which grants Owner-level authority. Invoked last because it hits the database. +public record OrganizationUserManageMembersRequest( + OrganizationUserType? ActingUserRole, + Permissions? ActingUserPermissions, + Func PermissionPicker, + Func> IsProviderUserForOrg); + +/// +/// Extends (see it for the acting-user fields) with the +/// target role, to decide whether the acting user may manage (or assign) that role without escalating privileges. +/// +/// The role being acted upon — an existing member's current role, or the new role being assigned. +public record OrganizationUserActionRequest( + OrganizationUserType? ActingUserRole, + Permissions? ActingUserPermissions, + Func PermissionPicker, + Func> IsProviderUserForOrg, + OrganizationUserType TargetUserRole) + : OrganizationUserManageMembersRequest(ActingUserRole, ActingUserPermissions, PermissionPicker, IsProviderUserForOrg); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/OrganizationUserActionValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/OrganizationUserActionValidator.cs new file mode 100644 index 000000000000..69547cacfa06 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/OrganizationUserActionValidator.cs @@ -0,0 +1,81 @@ +using Bit.Core.AdminConsole.Utilities.v2.Validation; +using Bit.Core.Enums; +using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationUserAction; + +/// +/// Validates that an acting user is permitted to manage a target member without escalating privileges, +/// according to Bitwarden's organization role hierarchy: +/// +/// Owners (and provider users) can manage Owners, Admins, or Users. +/// Admins can manage Admins or Users. +/// Custom users can manage Users and other Custom users, but only when they hold the manage permission required for the action. +/// All other members (including regular Users) cannot manage any member. +/// +/// A role can only be raised to a level the acting user already holds (e.g. only an Owner can grant the +/// Owner role). +/// +public static class OrganizationUserActionValidator +{ + /// + /// Validates whether the acting user can manage members at all, independent of any target. Use this where + /// authorization needs the gate without a specific target. Returns valid, or a . + /// + public static async Task> ValidateCanManageMembersAsync(OrganizationUserManageMembersRequest request) + { + var isProvider = !IsAuthorizedByRole(request) && await request.IsProviderUserForOrg(); + + return IsAuthorizedByRole(request) || isProvider + ? Valid(request) + : Invalid(request, new MissingManagePermissionError()); + } + + /// + /// Validates whether the acting user may manage (or assign) the target role: the management-authority gate + /// first, then role escalation. Returns valid, a if they cannot + /// manage members at all, or a if the target role outranks their authority. + /// + /// + /// Covers management authority and role escalation only. Callers remain responsible for other checks such as + /// self-actions, system/automated users, and confirmed-owner counts. + /// + public static async Task> ValidateAsync(OrganizationUserActionRequest request) + { + // First, the management-authority gate (shared with authorization). + var canManageResult = await ValidateCanManageMembersAsync(request); + if (canManageResult.IsError) + { + return Invalid(request, canManageResult.AsError); + } + + // The acting user can manage members; confirm the target's role is within their authority. A provider + // user has Owner-level authority, so treat them as an Owner. The expensive provider lookup already ran + // inside the gate above, so we reuse the cheap synchronous role check here rather than repeating it. + var effectiveRole = IsAuthorizedByRole(request) ? request.ActingUserRole : OrganizationUserType.Owner; + + return request.TargetUserRole switch + { + OrganizationUserType.Owner when effectiveRole is OrganizationUserType.Owner => Valid(request), + OrganizationUserType.Admin when effectiveRole is OrganizationUserType.Owner or OrganizationUserType.Admin => Valid(request), + OrganizationUserType.User or OrganizationUserType.Custom + when effectiveRole is OrganizationUserType.Owner or OrganizationUserType.Admin or OrganizationUserType.Custom => Valid(request), + _ => Invalid(request, new CannotManageHigherRoleError()) + }; + } + + /// + /// Whether the acting user can manage members by their organization role alone, without the provider lookup: + /// Owners and Admins always, and Custom users holding the action's permission. Cheap and synchronous, so it + /// is safe to evaluate more than once. + /// + private static bool IsAuthorizedByRole(OrganizationUserManageMembersRequest request) => + request.ActingUserRole switch + { + OrganizationUserType.Owner => true, + OrganizationUserType.Admin => true, + OrganizationUserType.Custom when request.ActingUserPermissions != null + && request.PermissionPicker(request.ActingUserPermissions) => true, + _ => false + }; +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/OrganizationUserActionValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/OrganizationUserActionValidatorTests.cs new file mode 100644 index 000000000000..505a55e94ed0 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/OrganizationUserAction/OrganizationUserActionValidatorTests.cs @@ -0,0 +1,207 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationUserAction; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationUserAction; + +public class OrganizationUserActionValidatorTests +{ + private static OrganizationUserActionRequest BuildRequest( + OrganizationUserType? actingRole, + OrganizationUserType targetRole, + bool hasManagePermission = false, + bool isProvider = false) + { + // A null acting role represents a non-member (e.g. a provider-only user). + var permissions = actingRole is null + ? null + : new Permissions { ManageUsers = hasManagePermission }; + + return new OrganizationUserActionRequest( + actingRole, + permissions, + p => p.ManageUsers, + () => Task.FromResult(isProvider), + targetRole); + } + + private static OrganizationUserManageMembersRequest BuildManageMembersRequest( + OrganizationUserType? actingRole, + bool hasManagePermission = false, + bool isProvider = false) + { + // A null acting role represents a non-member (e.g. a provider-only user). + var permissions = actingRole is null + ? null + : new Permissions { ManageUsers = hasManagePermission }; + + return new OrganizationUserManageMembersRequest( + actingRole, + permissions, + p => p.ManageUsers, + () => Task.FromResult(isProvider)); + } + + [Theory] + [InlineData(OrganizationUserType.Owner, false, OrganizationUserType.Owner)] + [InlineData(OrganizationUserType.Owner, false, OrganizationUserType.Admin)] + [InlineData(OrganizationUserType.Owner, false, OrganizationUserType.User)] + [InlineData(OrganizationUserType.Owner, false, OrganizationUserType.Custom)] + [InlineData(OrganizationUserType.Admin, false, OrganizationUserType.Admin)] + [InlineData(OrganizationUserType.Admin, false, OrganizationUserType.User)] + [InlineData(OrganizationUserType.Admin, false, OrganizationUserType.Custom)] + [InlineData(OrganizationUserType.Custom, true, OrganizationUserType.User)] + [InlineData(OrganizationUserType.Custom, true, OrganizationUserType.Custom)] + public async Task ValidateAsync_WhenActingUserCanManageTargetRole_ReturnsSuccess( + OrganizationUserType actingRole, + bool actingUserHasManagePermission, + OrganizationUserType targetRole) + { + // Note: Owners and Admins manage by role, so the manage permission is irrelevant for them. + var request = BuildRequest(actingRole, targetRole, hasManagePermission: actingUserHasManagePermission); + + var result = await OrganizationUserActionValidator.ValidateAsync(request); + + Assert.True(result.IsValid); + } + + [Theory] + [InlineData(OrganizationUserType.Admin, true, OrganizationUserType.Owner)] + [InlineData(OrganizationUserType.Custom, true, OrganizationUserType.Owner)] + [InlineData(OrganizationUserType.Custom, true, OrganizationUserType.Admin)] + public async Task ValidateAsync_WhenTargetRoleOutranksActingUser_ReturnsCannotManageHigherRoleError( + OrganizationUserType actingRole, + bool actingUserHasManagePermission, + OrganizationUserType targetRole) + { + var request = BuildRequest(actingRole, targetRole, hasManagePermission: actingUserHasManagePermission); + + var result = await OrganizationUserActionValidator.ValidateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + // A regular User cannot manage any member, regardless of target role. + [InlineData(OrganizationUserType.User, OrganizationUserType.Owner)] + [InlineData(OrganizationUserType.User, OrganizationUserType.Admin)] + [InlineData(OrganizationUserType.User, OrganizationUserType.User)] + [InlineData(OrganizationUserType.User, OrganizationUserType.Custom)] + public async Task ValidateAsync_WhenActingUserIsRegularUser_ReturnsMissingManagePermissionError( + OrganizationUserType actingRole, + OrganizationUserType targetRole) + { + var request = BuildRequest(actingRole, targetRole); + + var result = await OrganizationUserActionValidator.ValidateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + // A Custom user without the manage permission has no authority over any member. + [InlineData(OrganizationUserType.Owner)] + [InlineData(OrganizationUserType.Admin)] + [InlineData(OrganizationUserType.User)] + [InlineData(OrganizationUserType.Custom)] + public async Task ValidateAsync_WhenCustomUserLacksManagePermission_ReturnsMissingManagePermissionError( + OrganizationUserType targetRole) + { + var request = BuildRequest(OrganizationUserType.Custom, targetRole, hasManagePermission: false); + + var result = await OrganizationUserActionValidator.ValidateAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory] + [InlineData(OrganizationUserType.Owner)] + [InlineData(OrganizationUserType.Admin)] + [InlineData(OrganizationUserType.User)] + [InlineData(OrganizationUserType.Custom)] + public async Task ValidateAsync_WhenActingUserIsProvider_ReturnsSuccessForAnyTargetRole( + OrganizationUserType targetRole) + { + // A provider user has Owner-level authority. They are not a member of the organization, so their + // role/permissions are null and authority comes solely from the provider callback. + var request = BuildRequest(actingRole: null, targetRole, isProvider: true); + + var result = await OrganizationUserActionValidator.ValidateAsync(request); + + Assert.True(result.IsValid); + } + + [Fact] + public async Task ValidateAsync_WhenAuthorizedByRole_DoesNotInvokeProviderCallback() + { + // The provider check hits the database, so it must not be invoked when the role/permission checks + // already authorize the action. + var providerCallbackInvoked = false; + var request = new OrganizationUserActionRequest( + OrganizationUserType.Owner, + new Permissions(), + p => p.ManageUsers, + () => + { + providerCallbackInvoked = true; + return Task.FromResult(false); + }, + OrganizationUserType.User); + + var result = await OrganizationUserActionValidator.ValidateAsync(request); + + Assert.True(result.IsValid); + Assert.False(providerCallbackInvoked); + } + + [Theory] + [InlineData(OrganizationUserType.Owner)] + [InlineData(OrganizationUserType.Admin)] + public async Task ValidateCanManageMembersAsync_WhenOwnerOrAdmin_ReturnsSuccess( + OrganizationUserType actingRole) + { + var request = BuildManageMembersRequest(actingRole); + + var result = await OrganizationUserActionValidator.ValidateCanManageMembersAsync(request); + + Assert.True(result.IsValid); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ValidateCanManageMembersAsync_WhenCustomUser_DependsOnManagePermission( + bool hasManagePermission) + { + var request = BuildManageMembersRequest(OrganizationUserType.Custom, hasManagePermission); + + var result = await OrganizationUserActionValidator.ValidateCanManageMembersAsync(request); + + Assert.Equal(hasManagePermission, result.IsValid); + } + + [Fact] + public async Task ValidateCanManageMembersAsync_WhenProvider_ReturnsSuccess() + { + var request = BuildManageMembersRequest(actingRole: null, isProvider: true); + + var result = await OrganizationUserActionValidator.ValidateCanManageMembersAsync(request); + + Assert.True(result.IsValid); + } + + [Fact] + public async Task ValidateCanManageMembersAsync_WhenRegularUser_ReturnsMissingManagePermissionError() + { + var request = BuildManageMembersRequest(OrganizationUserType.User); + + var result = await OrganizationUserActionValidator.ValidateCanManageMembersAsync(request); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } +}