Skip to content

[PM-38923] feat: Add v2 UpdateOrganizationUser command with role-escalation validation#7919

Open
jrmccannon wants to merge 49 commits into
mainfrom
jmccannon/ac/pm-38923-update-orguser-v2
Open

[PM-38923] feat: Add v2 UpdateOrganizationUser command with role-escalation validation#7919
jrmccannon wants to merge 49 commits into
mainfrom
jmccannon/ac/pm-38923-update-orguser-v2

Conversation

@jrmccannon

@jrmccannon jrmccannon commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

PM-38923 (epic PM-28365 — "Manual single-email change for members without passwords")

📔 Objective

Refactors OrganizationUsersController.Put and the v1 UpdateOrganizationUserCommand into a v2 command + validator (returning CommandResult / ValidationResult<T>), mirroring the RevokeUser v2 pattern. The v2 path is gated behind the pm-28365-change-member-email-no-mp feature flag and stays off by default.

Key points:

  • Validator ports v1's request validation (invite-first, free-org admin, self-add-to-collection, collection/group existence, custom-permissions plan, confirmed-owner, Manage mutual-exclusivity).
  • Escalating-role guard — the previously-stubbed release blocker — now delegates to the shared IOrganizationUserValidationService.CanManage rules from [PM-38927] - Extract Organziation User Role Validation #7876, checking both the member's current role and the requested role so an existing higher-ranked member can't be modified from below. v1's specific error messages are preserved by mapping the denial (OnlyOwnersCanManageOwners / CustomUsersCannotManageAdminsOrOwners).
  • Zero-knowledge / boundaries — Core stays free of ICurrentContext / ClaimsPrincipal; the controller passes the acting user's membership (role + permissions) and the IActingUser as plain inputs. Collection resource authorization (ModifyUserAccess) stays in the controller.
  • Also fixes the Put collection-auth unit tests to mock the bulk ModifyUserAccess authorization call.

Test coverage

  • Core: v2 validator (incl. 7 escalation cases) + command tests.
  • Api: v2-path routing tests, and the repaired v1-path Put collection-auth tests (42/42 in the two controller test classes).

📸 Screenshots

@jrmccannon jrmccannon added the t:feature Change Type - Feature Development label Jul 2, 2026
@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 275 lines in your changes missing coverage. Please review.
✅ Project coverage is 14.80%. Comparing base (ddc714d) to head (ae887c4).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...s/UpdateUser/v2/UpdateOrganizationUserValidator.cs 0.00% 87 Missing ⚠️
...ers/UpdateUser/v2/UpdateOrganizationUserCommand.cs 0.00% 71 Missing ⚠️
...rAction/ManageOrganizationUserValidationService.cs 0.00% 51 Missing ⚠️
...ers/UpdateUser/v2/UpdateOrganizationUserRequest.cs 0.00% 17 Missing ⚠️
src/Core/AdminConsole/Entities/OrganizationUser.cs 0.00% 10 Missing ⚠️
...Features/OrganizationUsers/UpdateUser/v2/Errors.cs 0.00% 10 Missing ⚠️
src/Core/AdminConsole/Models/Data/StandardUser.cs 0.00% 6 Missing ⚠️
...ents/OrganizationDataOwnershipPolicyRequirement.cs 0.00% 6 Missing ⚠️
...e/AdminConsole/Models/Data/OrganizationUserRole.cs 0.00% 5 Missing ⚠️
...OrganizationUsers/OrganizationUserAction/Errors.cs 0.00% 5 Missing ⚠️
... and 4 more
Additional details and impacted files
@@             Coverage Diff             @@
##             main    #7919       +/-   ##
===========================================
- Coverage   62.45%   14.80%   -47.66%     
===========================================
  Files        2293     1394      -899     
  Lines       99797    60496    -39301     
  Branches     9007     4826     -4181     
===========================================
- Hits        62330     8957    -53373     
- Misses      35295    51388    +16093     
+ Partials     2172      151     -2021     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jrmccannon
jrmccannon force-pushed the jmccannon/ac/pm-38923-update-orguser-v2 branch 2 times, most recently from 41676f0 to 51a91f8 Compare July 8, 2026 13:50
Base automatically changed from jmccannon/ac/pm-38927-org-user-role-validation to main July 9, 2026 13:40
jrmccannon added 12 commits July 9, 2026 08:41
…lation validation

Refactor the OrganizationUsersController.Put flow into a v2 command + validator
(returning CommandResult / ValidationResult<T>), gated behind the
pm-28365-change-member-email-no-mp feature flag. The validator ports v1's request
validation and adds the escalating-role guard by delegating to the shared
IOrganizationUserValidationService.CanManage rules (PM-38927), while preserving
v1's specific error messages. The controller supplies the acting user's membership
(role + permissions) as a plain field so Core stays free of ICurrentContext.
… UpdateOrganizationUser

Pass the API-loaded collections into the v2 validation request so the validator
validates existence without re-querying. The validator now rejects default user
collections with a new CannotAssignDefaultCollection error instead of silently
filtering them, and the controller excludes current-access defaults from the
preserved read-only set.
…ator tests. Made sure self hosted couldn't increase seat count on SM.
Fold UpdateOrganizationUserValidationRequest into UpdateOrganizationUserRequest so
the validator consumes the command request directly. Rename request properties for
clarity (OrganizationUserToUpdate, New* for requested changes, ReferencedCollections)
and group them logically, and trim verbose comments across the request, command, and
validator.
…ationUser

Port the v1 grant-subset guard into the v2 validator so a Custom member with
only ManageUsers cannot grant a target member custom permissions the actor
does not themselves hold. Owners, admins, providers (no membership), and
system users remain exempt. Add the CustomUsersCanOnlyGrantOwnPermissions
error and validator coverage, and trim redundant comments across the v2
update files.
@jrmccannon
jrmccannon force-pushed the jmccannon/ac/pm-38923-update-orguser-v2 branch from c06e936 to 0cd8623 Compare July 9, 2026 13:42
Comment thread src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
r-tome
r-tome previously approved these changes Jul 9, 2026

@r-tome r-tome left a comment

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.

LGTM!

Comment thread src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
…ser validator

Assert the specific denial error type and add cases where the target's
current role and the requested role are Owner, exercising both branches of
the OnlyOwnersCanManageOwners vs CustomUsersCannotManageAdminsOrOwners mapping.
# Conflicts:
#	src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
@jrmccannon
jrmccannon marked this pull request as ready for review July 9, 2026 19:29
@jrmccannon
jrmccannon requested a review from a team as a code owner July 9, 2026 19:29
@jrmccannon
jrmccannon requested a review from eliykat July 9, 2026 19:29
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

Reviewed the refactor of OrganizationUsersController.Put and the new feature-flagged v2 UpdateOrganizationUser command + validator. The v2 path (Core, validator, escalation guard via IOrganizationUserValidationService.CanManage) is cleanly separated from the API layer, keeps zero-knowledge boundaries intact, and is well covered by unit tests. One v1/v2 behavior-parity gap warrants confirmation before merge.

Code Review Details
  • ⚠️ : v2 path drops the default-collection-on-demotion behavior present in v1 (model.DefaultUserCollectionName is never forwarded to the v2 command)
    • src/Api/AdminConsole/Controllers/OrganizationUsersController.cs:441

Note: The existing github-code-quality thread on model.Type.Value (line 447) was assessed and treated as a non-issue — Type is [Required] and the v1 path already dereferences it via ToOrganizationUser, so model validation guarantees non-null before the action runs.

Comment thread src/Api/AdminConsole/Controllers/OrganizationUsersController.cs
Comment on lines +40 to +46
// When admins are not allowed access to all collections, a user editing themselves cannot add
// themselves to collections they don't already have access to.
if (IsAddingSelfToCollection(request))
{
return Invalid(request, new CannotAddSelfToCollection());
}

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.

Claims are a good indication of authz logic but it's not exclusively claims based. Collections (and ciphers) use a resource-based authorization model (variously called relationship-based or fine-grained) where authorization depends on the relationship between objects. To me this is authorization because it is enforcing an access restriction based off the user's relationships; that org property effectively toggles between 2 different authorization schemes. It's all very convoluted due to the amount of configurability.

Another rule of thumb I think about: if this action were happening via the public api, would this check be needed? If not, that suggests that it's tied to the concept of a user and their permissions which is authorization.

This is all negotiable, but even if I'm wrong here I think we should defer it and consider together with the rest of the logic that lives at the controller layer, rather than moving it in here at this stage.

EDIT: this also lets you drop CurrentCollectionsIds from the request object, which helps with CurrentCollectionsIds/ReferencedCollections/NewCollections confusion.

Comment on lines +104 to +135
private async Task<bool> IsValidFreeOrganizationAdminAsync(OrganizationUser organizationUser, OrganizationUserType newType, Organization organization)
{
if (organization.PlanType != PlanType.Free)
{
return true;
}

if (!organizationUser.UserId.HasValue)
{
return true;
}

if (newType is not (OrganizationUserType.Admin or OrganizationUserType.Owner))
{
return true;
}

var adminCount = await organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(organizationUser.UserId!.Value);
var isCurrentAdminOrOwner = organizationUser.Type is OrganizationUserType.Admin or OrganizationUserType.Owner;

if (isCurrentAdminOrOwner && adminCount <= 1)
{
return true;
}

if (!isCurrentAdminOrOwner && adminCount == 0)
{
return true;
}

return false;
}

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.

Doing a text search of part of this error mesage ("one free organization") shows that it's reused in a number of flows:

  • accept
  • confirm
  • update
  • new org

Could it be moved to the validation service in a way that is general enough to be reusable?

Comment on lines +503 to +509
// 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();

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.

I have very low confidence in this existing code so I don't want to modify it here if we can help it. I think this will break, because an admin never has write access to a My Items collection, yet it'll be excluded from readonlyCollectionAccess which needs to be added back in to the set, which means the user will lose access to it. But regardless; if there is no documented defect here let's leave it.

Moving it into a private method makes sense (for re-use between v1 and v2 flows) but the guts of it shouldn't change.

Comment on lines +58 to +64
await organizationUserRepository.ReplaceAsync(organizationUser, request.NewCollections?.ToList() ?? []);

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

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.

Update: after looking at the controller again, this is matching the api interface and the legacy authz logic. None of that's ideal but as discussed not worth fixing here. So I think this can stay as is. (context)

/// <param name="NewGroups">The updated group access; null leaves groups unchanged.</param>
/// <param name="PerformedByOrganizationUser">The actor's own membership; null when the actor is not an organization member (e.g. a provider).</param>
/// <param name="DefaultUserCollectionName">Default collection name used when applicable</param>
public record UpdateOrganizationUserRequest(

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 authz logic at the controller continues to do us dirty here and makes it difficult to decouple ourselves from the legacy implementation. But I think you've improved this and it's OK. (context)

OrganizationUser OrganizationUserToUpdate,
Organization Organization,
HashSet<Guid> CurrentCollectionsIds,
ICollection<Collection> ReferencedCollections,

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.

One remaining concern I have is ReferencedCollections, which is raw POST data which is not authorized. Passing the unauthorized request through like this seems like a bit of a footgun. Maybe as a rule, if an object is missing then you're not authorized to act on it, so it fails at the controller layer; then the command is only receiving an authorized request (which is the current intention)? Open to discussion.

@eliykat eliykat left a comment

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.

Review completed. Great job, thank you for taking the lead on the service layer, I think it works. I also think passing the same request object from controller -> command -> validator is an improvement. We can discuss more when we catch up next week, I'm interested in what you think.

Comment on lines +68 to +73
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.ReplaceAsync(organizationUser, Arg.Any<IEnumerable<CollectionAccessSelection>>());
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.UpdateGroupsAsync(organizationUser.Id, Arg.Any<IEnumerable<Guid>>(), Arg.Any<DateTime>());

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.

Can we be more specific than Arg.Any? (and throughout, I think Claude tends towards this 'cause it's lazy)


[Theory]
[BitAutoData]
public async Task UpdateUserAsync_AppliesRequestedChangesToTheDatabaseCopy(

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 test case doesn't match the assertions - nothing here mentions the database. A more accurate description would be "returns an updated organization user". That could be covered in your basic happy path test above though.

Comment on lines +118 to +161
[Theory]
[BitAutoData]
public async Task UpdateUserAsync_WhenEnablingSecretsManager_ChecksRequiredSeats(
SutProvider<UpdateOrganizationUserCommand> sutProvider,
Organization organization,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser)
{
organizationUser.AccessSecretsManager = false;
var request = Setup(sutProvider, organization, organizationUser, targetAccessSecretsManager: true);

sutProvider.GetDependency<ICountNewSmSeatsRequiredQuery>()
.CountNewSmSeatsRequiredAsync(organization.Id, 1)
.Returns(0);

var result = await sutProvider.Sut.UpdateUserAsync(request);

Assert.True(result.IsSuccess);

await sutProvider.GetDependency<ICountNewSmSeatsRequiredQuery>()
.Received(1)
.CountNewSmSeatsRequiredAsync(organization.Id, 1);
await sutProvider.GetDependency<IUpdateSecretsManagerSubscriptionCommand>()
.DidNotReceive()
.UpdateSubscriptionAsync(Arg.Any<SecretsManagerSubscriptionUpdate>());
}

[Theory]
[BitAutoData]
public async Task UpdateUserAsync_WhenSecretsManagerAccessUnchanged_DoesNotCheckSeats(
SutProvider<UpdateOrganizationUserCommand> sutProvider,
Organization organization,
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser)
{
organizationUser.AccessSecretsManager = false;
var request = Setup(sutProvider, organization, organizationUser, targetAccessSecretsManager: false);

var result = await sutProvider.Sut.UpdateUserAsync(request);

Assert.True(result.IsSuccess);

await sutProvider.GetDependency<ICountNewSmSeatsRequiredQuery>()
.DidNotReceive()
.CountNewSmSeatsRequiredAsync(Arg.Any<Guid>(), Arg.Any<int>());
}

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.

Whether it checks seats or not is an internal implementation detail and doesn't tell you much about correctness. Maybe it checks it but does nothing with the result. What is the external behavior we want to verify? "Scales secrets manager subscription when required" / "Does not scale secrets manager subscription when not required".


[Theory]
[BitAutoData]
public async Task Put_WhenFeatureFlagEnabled_ExcludesDefaultCollectionsFromPreservedAccess(

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.

Asserts incorrect behavior - as mentioned above, this will result in the My Items collection access being removed for the target user.

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.

Please add some api integration tests. I think they will be more useful here given the amount of state and mocking involved.

@jrmccannon
jrmccannon requested a review from eliykat July 22, 2026 14:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants