-
Notifications
You must be signed in to change notification settings - Fork 1.6k
[PM-36678] Fix custom users modifying admins for restore and revoke. #7601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| ο»Ώnamespace Bit.Core.AdminConsole.Enums; | ||
|
|
||
| public enum OrganizationUserActionType | ||
| { | ||
| Remove, | ||
| Revoke, | ||
| Restore, | ||
| } | ||
| 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( | ||
|
|
@@ -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 | ||
|
|
@@ -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) | ||
| { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: Lines 76 to 84 in 963c160
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: Some outstanding questions we need to address:
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scope
Exactly - you can think of them as 3 separate business rules:
But in reality these will always go together - so I think they're actually expressing a single larger business rule:
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 layerYou're right about authz handlers not handling bulk cases well - an
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is just me thinking out loud here.
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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
| { | ||
| // 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()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Although this is async,
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you clarify which pattern youβre referring to here: 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think having
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Since it's already cached, should we just call await
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You already would be at a certain point. Honestly you could probably get away with using the CurrentContextOrg which is sync. That has access to Permissions and org user type. Side Note: 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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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β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); | ||
| } | ||

There was a problem hiding this comment.
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.