Skip to content

[PM-41861] Add Ability to Invited Staged Users - #8283

Open
sven-bitwarden wants to merge 3 commits into
mainfrom
pm-41861
Open

[PM-41861] Add Ability to Invited Staged Users#8283
sven-bitwarden wants to merge 3 commits into
mainfrom
pm-41861

Conversation

@sven-bitwarden

@sven-bitwarden sven-bitwarden commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

PM-41861

📔 Objective

We need the ability to invite staged users with the same configuration capabilities as regular/net-new users. This PR surgically modifies OrganizationService to do so, while providing a new API entrypoint to invite for the dedicated row action.

Because this PR modifies OrganizationService, I have added quite a few integration tests on this behavior.

Proof

Screen.Recording.2026-08-31.at.10.51.22.AM.mov
@sven-bitwarden sven-bitwarden added ai-review-vnext Request a Claude code review using the vNext workflow t:feature Change Type - Feature Development labels Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

Re-reviewed the full change end to end. The seat arithmetic in SaveUsersSendInvitesAsync holds — Organization_ReadOccupiedSeatCountByOrganizationId counts only Status IN (0,1,2), so a staged row occupies no seat and charging one per promotion is right, and the inviteWithSmAccessCount change follows the same reasoning; InviteStagedOrganizationUsersCommand reserves Password Manager seats before Secrets Manager seats so the subscription never sees more SM than PM seats, and AutoAddSeatsAsync mutates the same Organization instance the SM update is built from. Authorization on POST send-invite matches every sibling bulk endpoint (ManageUsersRequirement plus the PM34423StagedStatus gate), ValidateOrganizationUserUpdatePermissions still runs ahead of every invite including staged promotions, and the narrowed BadRequestException or GatewayException filters surface only messages ExceptionHandlerFilterAttribute already returns verbatim. The new OrganizationUser_ReadByOrganizationIdEmails has parity across the Dapper stored procedure, the correctly named migration, and the EF LINQ implementation, with [DatabaseData] tests covering status-independence, organization scoping, and the confirmed-member case.

One new finding: the invite-dialog revert path is no longer self-consistent now that promoted staged rows are excluded from the delete but seats are still rolled back.

Code Review Details
  • ⚠️ : Mid-loop failure while persisting staged promotions makes the seat revert throw, discarding the original exception and leaving seats expanded
    • src/Core/AdminConsole/Services/Implementations/OrganizationService.cs:692-713
Comment on lines +73 to +81
foreach (var organizationUser in organizationUsers)
{
organizationUser.Status = OrganizationUserStatusType.Invited;
// The update stored procedure persists whatever RevisionDate the entity carries, so bump it here or
// the row's watermark stays at its staged-creation timestamp and watermark-driven consumers miss the
// change.
organizationUser.RevisionDate = revisionDate;
await organizationUserRepository.ReplaceAsync(organizationUser);
}

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.

🎨 SUGGESTED: ReplaceManyAsync would collapse this loop (and the revert loop) into one round trip and make the batch update atomic.

Details and fix

RunAsync accepts an arbitrary list of ids, so this issues one OrganizationUser_Update round trip per member, and the revert loop at lines 97-102 issues another. IOrganizationUserRepository.ReplaceManyAsync sends the whole set to [dbo].[OrganizationUser_UpdateMany] in a single call; ConfirmOrganizationUserCommand.cs:177 already uses it for the same bulk status-change shape.

foreach (var organizationUser in organizationUsers)
{
    organizationUser.Status = OrganizationUserStatusType.Invited;
    organizationUser.RevisionDate = revisionDate;
}

await organizationUserRepository.ReplaceManyAsync(organizationUsers);

Beyond the N→1 round trips, it also closes a partial-failure gap: this loop sits outside the try, so if ReplaceAsync throws on member 3 of 5, members 1-2 are left Invited with no invitation email sent and no revert path, while seats were already added. A single UPDATE ... FROM OPENJSON cannot land half-applied.

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.71981% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.45%. Comparing base (25f1f9c) to head (510587f).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
...onFeatures/OrganizationUsers/StagedUsers/Errors.cs 50.00% 5 Missing ⚠️
...le/Services/Implementations/OrganizationService.cs 89.36% 3 Missing and 2 partials ⚠️
...tagedUsers/InviteStagedOrganizationUsersCommand.cs 97.32% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8283      +/-   ##
==========================================
+ Coverage   63.66%   69.45%   +5.79%     
==========================================
  Files        2430     2470      +40     
  Lines      104962   105892     +930     
  Branches     9506     9571      +65     
==========================================
+ Hits        66822    73552    +6730     
+ Misses      35844    29905    -5939     
- Partials     2296     2435     +139     

☔ 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.
return new OrganizationUserNotStaged();
}

var seatReservationError = await ReserveSeatsAsync(organization, organizationUsers.Count);

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.

QUESTION: Should this path also account for Secrets Manager seats, the way the invite-dialog path does?

Details

OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId counts Status IN (0, 1, 2) and AccessSecretsManager = 1, so a staged member holding SM access occupies no SM seat until this command flips them to Invited — the same reason ReserveSeatsAsync exists for PM seats.

Staged members can be pre-configured before anyone invites them (this PR's own test Invite_WhenTheDialogSelectsNoAccess_LeavesTheStagedMembersExistingAccessAlone relies on that), and UpdateOrganizationUserCommand will happily set AccessSecretsManager on a staged row. Because TryEnablingSecretsManagerAsync sizes against an occupancy count that excludes staged rows, enabling SM on two staged members can each see the same free seat, and promoting both here pushes the org past SmSeats with no autoscale.

SaveUsersSendInvitesAsync was changed in this PR to count staged promotions toward inviteWithSmAccessCount for exactly this reason; is the row action intentionally left out, or is SM access on staged members considered out of scope for now?

// Directory Connector key off both, and only the fields the invite specifies are overwritten.
foreach (var (orgUser, invite) in stagedInvitations.Values)
{
orgUser.Type = invite.Type.Value;
@sven-bitwarden
sven-bitwarden marked this pull request as ready for review August 31, 2026 15:53
@sven-bitwarden
sven-bitwarden requested review from a team as code owners August 31, 2026 15:53
Comment on lines +692 to +713
// Staged users' changes are handled separately to avoid unnecessary conditions above
foreach (var (orgUser, invite) in stagedInvitations.Values)
{
if (invite.Collections != null && invite.Collections.Any())
{
await _organizationUserRepository.ReplaceAsync(orgUser, invite.Collections);
}
else
{
await _organizationUserRepository.ReplaceAsync(orgUser);
}

if (invite.Groups != null && invite.Groups.Any())
{
await _organizationUserRepository.UpdateGroupsAsync(orgUser.Id, invite.Groups, revisionDate);
}
}
}
catch (Exception e)
{
// Revert any added users.
var invitedOrgUserIds = allOrgUsers.Select(ou => ou.Id);
await _organizationUserRepository.DeleteManyAsync(invitedOrgUserIds);
// Revert any created/non-staged users.
await _organizationUserRepository.DeleteManyAsync(createdOrgUsers.Select(ou => ou.Id));

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.

⚠️ IMPORTANT: A failure part-way through this loop makes the seat revert below throw, which swallows the real error and leaves seats expanded.

Details and fix

The revert no longer removes the promoted staged rows (correct — they pre-date the call), but it still shrinks Seats back to initialSeatCount. Those two are now inconsistent when the loop fails after at least one ReplaceAsync has committed.

Trace, with an org whose seats are full (availableSeats == 0, which is exactly why AutoAddSeatsAsync ran) and two staged members being invited:

  1. AutoAddSeatsAsync raises Seats from initialSeatCount to initialSeatCount + 2.
  2. SendInvitesAsync succeeds — both members are emailed.
  3. Member 1 is replaced as Invited; member 2's ReplaceAsync hits a transient DB failure (deadlock/timeout).
  4. Catch: DeleteManyAsync removes nothing (createdOrgUsers is empty here).
  5. AdjustSeatsAsync(organization, initialSeatCount - currentSeats) reaches its occupancy guard (OrganizationService.cs:249-268). GetOccupiedSeatCountByOrganizationIdAsync now counts member 1, because Status IN (0,1,2) includes Invited, so seatCounts.Total == initialSeatCount + 1 > newSeatTotal and it throws BadRequestException("Your organization currently has N seats filled. Remove some users.").

Because that throw happens before exceptions.Add(e) on line 735, the original exception is discarded and the caller sees the misleading "Remove some users" 400 instead. Seats also stays at the autoscaled value, since the revert never completed.

Before this change the revert deleted every row in allOrgUsers, so occupancy always returned to its starting value and this branch was unreachable.

Restoring the promoted rows in the catch keeps occupancy consistent with the seat revert:

catch (Exception e)
{
    // Revert any created/non-staged users.
    await _organizationUserRepository.DeleteManyAsync(createdOrgUsers.Select(ou => ou.Id));

    // Put any staged rows already promoted above back the way their provisioning tool left them.
    var promotedStagedUsers = stagedInvitations.Values
        .Select(s => s.OrgUser)
        .Where(ou => ou.Status == OrganizationUserStatusType.Invited)
        .ToList();
    if (promotedStagedUsers.Count > 0)
    {
        foreach (var orgUser in promotedStagedUsers)
        {
            orgUser.Status = OrganizationUserStatusType.Staged;
        }

        await _organizationUserRepository.ReplaceManyAsync(promotedStagedUsers);
    }

    var currentOrganization = await _organizationRepository.GetByIdAsync(organization.Id);
    // ...
}

Alternatively, wrapping the seat revert so a failure there cannot displace e would at least preserve the real error for the caller and the logs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review-vnext Request a Claude code review using the vNext workflow t:feature Change Type - Feature Development

1 participant