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
8 changes: 8 additions & 0 deletions src/Core/AdminConsole/Enums/OrganizationUserActionType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ο»Ώnamespace Bit.Core.AdminConsole.Enums;

public enum OrganizationUserActionType
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal of this was to allow the commands to just pass in the action, while keeping the error messages in the validator. This is mainly to reduce logic in the commands. Also, most of the messages are the same, so centralizing them is a good idea.

I thought about passing in the class type, but that would narrow the calling code to only those types. An enum seems like the easiest solution, but I’m open to ideas.

{
Remove,
Revoke,
Restore,
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
#nullable disable

using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validators;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
Expand Down Expand Up @@ -31,7 +33,8 @@ public class RestoreOrganizationUserCommand(
IPolicyRequirementQuery policyRequirementQuery,
ICollectionRepository collectionRepository,
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator,
IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand) : IRestoreOrganizationUserCommand
IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand,
ICustomUserActingOnAdminValidator customUserActingOnAdminValidator) : IRestoreOrganizationUserCommand
{
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName)
{
Expand All @@ -46,6 +49,8 @@ public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? rest
throw new BadRequestException("Only owners can restore other owners.");
}

await customUserActingOnAdminValidator.EnforceAsync(organizationUser, OrganizationUserActionType.Restore);

await RepositoryRestoreUserAsync(organizationUser, defaultCollectionName);
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);

Expand Down Expand Up @@ -218,6 +223,8 @@ public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid
throw new BadRequestException("Only owners can restore other owners.");
}

await customUserActingOnAdminValidator.EnforceAsync(organizationUser, OrganizationUserActionType.Restore);

var twoFactorIsEnabled = organizationUser.UserId.HasValue
&& organizationUsersTwoFactorEnabled
.FirstOrDefault(ou => ou.userId == organizationUser.UserId.Value)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
ο»Ώusing Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
ο»Ώusing Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validators;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
Expand All @@ -14,7 +16,8 @@ public class RevokeOrganizationUserCommand(
IPushNotificationService pushNotificationService,
IOrganizationUserRepository organizationUserRepository,
ICurrentContext currentContext,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery)
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
ICustomUserActingOnAdminValidator customUserActingOnAdminValidator)
: IRevokeOrganizationUserCommand
{
public async Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId, RevocationReason reason)
Expand All @@ -30,6 +33,8 @@ public async Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revok
throw new BadRequestException("Only owners can revoke other owners.");
}

await customUserActingOnAdminValidator.EnforceAsync(organizationUser, OrganizationUserActionType.Revoke);

await RepositoryRevokeUserAsync(organizationUser, reason);
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ public record UserAlreadyRevoked() : BadRequestError("Already revoked.");
public record CannotRevokeYourself() : BadRequestError("You cannot revoke yourself.");
public record OnlyOwnersCanRevokeOwners() : BadRequestError("Only owners can revoke other owners.");
public record MustHaveConfirmedOwner() : BadRequestError("Organization must have at least one confirmed owner.");
public record CustomUserCannotRevokeAdmin() : BadRequestError("Custom users can not revoke admins.");
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
ο»Ώusing Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validators;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Entities;
using Bit.Core.Enums;
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;

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

public class RevokeOrganizationUsersValidator(IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery)
public class RevokeOrganizationUsersValidator(
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
ICustomUserActingOnAdminValidator customUserActingOnAdminValidator)
: IRevokeOrganizationUserValidator
{
public async Task<ICollection<ValidationResult<OrganizationUser>>> ValidateAsync(
Expand All @@ -17,6 +20,8 @@ public async Task<ICollection<ValidationResult<OrganizationUser>>> ValidateAsync
request.OrganizationUsersToRevoke.Select(x => x.Id) // users excluded because they are going to be revoked
);

var customUserCannotRevokeAdmin = await CustomUserCannotRevokeAdminAsync(request.OrganizationUsersToRevoke);

return request.OrganizationUsersToRevoke.Select(x =>
{
return x switch
Expand All @@ -32,9 +37,20 @@ _ when request.PerformedBy is not SystemUser
{ Type: OrganizationUserType.Owner } when request.PerformedBy is not SystemUser
&& !request.PerformedBy.IsOrganizationOwnerOrProvider =>
Invalid(x, new OnlyOwnersCanRevokeOwners()),
{ Type: OrganizationUserType.Admin } when customUserCannotRevokeAdmin =>
Invalid(x, new CustomUserCannotRevokeAdmin()),

_ => Valid(x)
};
}).ToList();
}

// Probes with any admin in the batch. The rule's answer is uniform across every admin
// in the same organization, so one cached lookup covers the whole batch.
private async Task<bool> CustomUserCannotRevokeAdminAsync(IEnumerable<OrganizationUser> users)
{
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still need to iterate on this a bit.

var anyAdmin = users.FirstOrDefault(x => x.Type == OrganizationUserType.Admin);
return anyAdmin is not null
&& await customUserActingOnAdminValidator.IsBlockedAsync(anyAdmin);
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too narrow - the general pattern here is not "custom users cannot act on admins" (specific), it's "users with lower privileges cannot act on users with higher privileges" (more general).

Here is some example logic from account recovery which fully expresses the rule:

// Current user must have equal or greater permissions than the user account being recovered
var authorized = targetOrganizationUser.Type switch
{
OrganizationUserType.Owner => currentContextOrganization.Type is OrganizationUserType.Owner,
OrganizationUserType.Admin => currentContextOrganization.Type is OrganizationUserType.Owner or OrganizationUserType.Admin,
_ => currentContextOrganization is
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin }
or { Type: OrganizationUserType.Custom, Permissions.ManageResetPassword: true }
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My original intention wasn’t to have a general role privileges check, but we can definitely pivot to that.

We have another similar rule: Only owners can remove other owners which could also be part of this validator.

Some outstanding questions we need to address:

  1. If we want to turn this into an AuthorizationHandler, then we need to address these concerns.
  2. If we want to keep the current pattern, I can rename this validator to something more generic so we can add other rules to it later. In general, I prefer to keep bug fixes small so they’re easier to test and roll out, but I’m also happy to include the other rules here as well. Let me know what you think.

Copy link
Copy Markdown
Member

@eliykat eliykat May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scope

We have another similar rule: Only owners can remove other owners which could also be part of this validator.

Exactly - you can think of them as 3 separate business rules:

  • only owners can remove owners
  • only owners or admins can remove admins
  • only owners, admins or customer users (with Manage Users) can remove anyone else

But in reality these will always go together - so I think they're actually expressing a single larger business rule:

  • To remove someone, you must have the same role as them or a superior role

And so it makes sense to cover all 3 cases together in the same place. I think it's worth doing here so that the solution can be reused across all relevant flows (e.g. revoke, remove, delete, reset password... any others?)

Authorization vs. core layer

You're right about authz handlers not handling bulk cases well - an AuthorizationResult can contain one or more reasons (example), but I think it would be really cumbersome to try to correlate those back to the users. I'm OK with keeping it as a single-purpose validator so that the result can flow back through our command/result pattern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just me thinking out loud here.

  1. What if we make the error message more generic, like: "Unable to perform action because your role is below the target user"? Then we could reuse this validator anywhere and encapsulate all the role checks in one place.
  2. We could create an authorization handler that simply calls the validator. The validator could also be used directly in commands for bulk actions.

Note: Ideally, it would be nice to have all the validation in one place so it's easier to check and verify, but I understand that authorization is slightly different from other forms of validation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • To remove someone, you must have the same role as them or a superior role

And so it makes sense to cover all 3 cases together in the same place. I think it's worth doing here so that the solution can be reused across all relevant flows (e.g. revoke, remove, delete, reset password... any others?)

We need to make sure we are explicit about which roles can perform the revoke / remove action on which other roles as we laid out in the 3 rules. We should use that for the implementation and not rely on the ordinal comparison using enum values.

Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
ο»Ώusing Bit.Core.AdminConsole.Enums;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validators;

public class CustomUserActingOnAdminValidator(ICurrentContext currentContext) : ICustomUserActingOnAdminValidator
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @sven-bitwarden , this is a new pattern I’m trying out. Happy to get any feedback.

We’re obviously doing the same check in a lot of places, so I was thinking we could abstract it into a single validator.

Benefits:

  1. Keeps the logic DRY
  2. Handles both single-user and bulk-user org cases without requiring multiple async calls.

{
// Memoize OrganizationCustom answers for the lifetime of this request-scoped instance.
private readonly Dictionary<Guid, bool> _organizationCustomLookup = new();

public async Task EnforceAsync(OrganizationUser targetUser, OrganizationUserActionType actionType)
{
if (await IsBlockedAsync(targetUser))
{
throw new BadRequestException(actionType.ToCustomUserCannotModifyAdminMessage());
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validator should follow our new pattern of not throwing errors for business logic failure. Have a single public method that returns a value; then callers can decide how to handle that value. (e.g. v1 command can throw, v2 command can return it out as a result)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two methods in this validator: one for the old approach (v1 method: EnforceAsync), which throws an exception, and another for the new approach (v2 method: IsBlockedAsync), which just returns a bool.

I can change v2 to return the error message class if you'd like. One concern is that different actions have slightly different error messages. We could pass in an enum similar to how I'm handling it in v1.

I haven't put too much thought into v2 yet, so I'm open to ideas. I'd like to avoid doing multiple checks where the calling code has to inspect the result. Ideally, we would only need a single check.

In v1, we're effectively doing one check since the calling code handles the exception. With v2, we might end up needing a double check.

}
}

public async Task<bool> IsBlockedAsync(OrganizationUser targetUser)
{
if (targetUser.Type != OrganizationUserType.Admin)
{
return false;
}

return await IsActingUserCustomAsync(targetUser.OrganizationId);
}

private async Task<bool> IsActingUserCustomAsync(Guid organizationId)
{
if (_organizationCustomLookup.TryGetValue(organizationId, out var cached))
{
return cached;
}

var isCustom = await currentContext.OrganizationCustom(organizationId);
_organizationCustomLookup[organizationId] = isCustom;
Comment on lines +39 to +40
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although this is async, currentContext caches its results locally so you don't need a second layer of caching here. (I also generally think that this is an antipattern so would be pretty hesitant to extend this kind of behavior)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you clarify which pattern you’re referring to here: currentContext or local caching?

If you mean the local caching approach, could you elaborate a bit more on why you see it as an anti-pattern? I don’t think I fully understand the tradeoffs yet. The main concern I can think of is stale data if the cache is long-lived, but in this case I made sure it only lives for the duration of the request by registering the validator as AddScoped in DI.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think having ICurrentContext as a request level object that does a bunch of things off the bat every request is the anti-pattern he's referring to. I think it would be more preferred if there were smaller more focused contexts depending on the request that is occurring. ICurrentContext does too much at this point. But using it is better than taking something out of CurrentContext to store in another spot.

ICurrentContext is populated every request via middleware. By creating another scoped entity, you're caching the cache, which is also an antipattern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds like we want to remove my cache layer and just use ICurrentContext's cache. I'm okay with this. My main goal with adding the cache was to move away from this pattern for bulk operations, so single and bulk validation can be more aligned.

image

Since it's already cached, should we just call await _currentContext.OrganizationCustom(organizationId); for each user moving forward?

Copy link
Copy Markdown
Contributor

@jrmccannon jrmccannon May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You already would be at a certain point. It doesn't do a db lookup every time. Just once at the start and then is awaited everywhere else due to how the async/await works (because of lazy loading of provider data). See below

Honestly you could probably get away with using the CurrentContextOrg which is sync. That has access to Permissions and org user type.

Side Note:

    public Task<bool> OrganizationCustom(Guid orgId)
    {
        return Task.FromResult(Organizations?.Any(o => o.Id == orgId && o.Type == OrganizationUserType.Custom) ?? false);
    }

This shouldn't even be async.

return isCustom;
}
}

internal static class OrganizationUserActionExtensions
{
public static string ToCustomUserCannotModifyAdminMessage(this OrganizationUserActionType actionType) => actionType switch
{
OrganizationUserActionType.Remove => "Custom users can not remove admins.",
OrganizationUserActionType.Revoke => "Custom users can not revoke admins.",
OrganizationUserActionType.Restore => "Custom users can not restore admins.",
_ => throw new ArgumentOutOfRangeException(nameof(actionType), actionType, null),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
ο»Ώusing Bit.Core.AdminConsole.Enums;
using Bit.Core.Entities;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validators;

public interface ICustomUserActingOnAdminValidator
Copy link
Copy Markdown
Contributor Author

@JimmyVo16 JimmyVo16 May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving the discussion to this comment here so it’ll be easier to comment on.

I think this should be handled by the authorization layer

I’m not against this idea since it’s role-related. One concern is that the *AuthorizationHandler works great for single use cases, but how do we handle bulk actions? We’re running into the same problem as the ticket that checks whether orgUser belongs to the org during bulk actions.

Another concern is that AuthorizationHandler normally just returns pass or fail, while in our case, we return meaningful errors that are displayed to the user depending on the scenario. Changing this would be a breaking change.

edit: cc @eliykat

{
/// <summary>
/// Throws <see cref="Bit.Core.Exceptions.BadRequestException"/> when the current acting user
/// has the Custom role in the target user's organization and the target user is an Admin.
/// Custom users (even those granted the ManageUsers permission) are not permitted to modify
/// Admins under the role hierarchy. The exception message is scoped to the supplied actionType.
/// </summary>
/// <param name="targetUser">The organization user that the acting user is attempting to modify.</param>
/// <param name="actionType">The actionType being attempted; selects the error message.</param>
Task EnforceAsync(OrganizationUser targetUser, OrganizationUserActionType actionType);

/// <summary>
/// Returns true when the current acting user has the Custom role in the target user's
/// organization and the target user is an Admin. Custom users (even those granted the
/// ManageUsers permission) are not permitted to modify Admins under the role hierarchy.
/// </summary>
/// <param name="targetUser">The organization user that the acting user is attempting to modify.</param>
Task<bool> IsBlockedAsync(OrganizationUser targetUser);
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Validators;
using Bit.Core.Models.Business.Tokenables;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Cloud;
Expand Down Expand Up @@ -227,6 +228,7 @@ private static void AddOrganizationUserCommandsQueries(this IServiceCollection s

services.AddScoped<IAuthorizationHandler, OrganizationUserUserDetailsAuthorizationHandler>();
services.AddScoped<IHasConfirmedOwnersExceptQuery, HasConfirmedOwnersExceptQuery>();
services.AddScoped<ICustomUserActingOnAdminValidator, CustomUserActingOnAdminValidator>();

services.AddScoped<IInviteOrganizationUsersCommand, InviteOrganizationUsersCommand>();
services.AddScoped<ISendOrganizationInvitesCommand, SendOrganizationInvitesCommand>();
Expand Down
Loading