Skip to content

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

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

[PM-41861] Add Ability to Invited Staged Users#8283
sven-bitwarden wants to merge 4 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 after the switch to a bulk response model. The earlier ReplaceManyAsync suggestion and the Secrets Manager seat question are both addressed: InviteStagedOrganizationUsersCommand now does one bulk update per direction and reserves SM seats after PM seats, so the subscription never sees more SM than PM seats. The new Partition split is sound — ids from another organization collapse into StagedOrganizationUserNotFound so the response leaks nothing across organizations, seat reservation is sized off eligible.Count, and the empty-eligible short circuit avoids a pointless autoscale. The controller's per-row shape ("" on success, error.Message on failure) matches every sibling bulk endpoint, OrganizationNotFound still maps to 404 and the seat errors to 400 via MapError, and the OrganizationUser_ReadByOrganizationIdEmails stored procedure, migration, and EF LINQ implementation stay in parity with each other and with the existing GetByOrganizationEmailAsync matching semantics.

Two findings remain, both in the seat-rollback paths rather than the invite logic itself.

Code Review Details
  • ⚠️ : Secrets Manager reservation failure leaves the just-purchased Password Manager seats in place with nobody invited
    • src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/StagedUsers/InviteStagedOrganizationUsersCommand.cs:53-64
  • ⚠️ : Mid-loop failure while persisting staged promotions makes the seat revert throw, discarding the original exception and leaving seats expanded (open from the previous review)
    • 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 94.44444% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.55%. Comparing base (25f1f9c) to head (d9534c1).
⚠️ Report is 12 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.77% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8283      +/-   ##
==========================================
+ Coverage   63.66%   69.55%   +5.89%     
==========================================
  Files        2430     2473      +43     
  Lines      104962   106145    +1183     
  Branches     9506     9620     +114     
==========================================
+ Hits        66822    73829    +7007     
+ Misses      35844    29877    -5967     
- Partials     2296     2439     +143     

☔ 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.

Comment on lines +53 to +64
var seatReservationError = await ReserveSeatsAsync(organization, eligible.Count);
if (seatReservationError is not null)
{
return seatReservationError;
}

// Password Manager seats first: the subscription rejects more Secrets Manager seats than it has PM seats.
var secretsManagerSeatReservationError = await ReserveSecretsManagerSeatsAsync(organization, eligible);
if (secretsManagerSeatReservationError is not null)
{
return secretsManagerSeatReservationError;
}

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: If the Secrets Manager reservation fails, the Password Manager seats bought a few lines above stay bought even though nobody is invited.

Details and fix

ReserveSeatsAsync is not a dry run — AutoAddSeatsAsyncAdjustSeatsAsync charges the gateway and persists the new Seats (OrganizationService.cs:291-296). When ReserveSecretsManagerSeatsAsync then returns an error, RunAsync returns straight to the caller: no member is promoted, the admin gets a 400, and the organization keeps paying for the seats.

Trace, for an org with UseSecretsManager, no PM headroom, and two staged members carrying AccessSecretsManager:

  1. ReserveSeatsAsync autoscales PM seats by 2 — Stripe is charged, Organization.Seats is written.
  2. ReserveSecretsManagerSeatsAsync calls UpdateSubscriptionAsync, which throws BadRequestException (SM autoscale cap, or MaxAutoscaleSmSeats reached).
  3. Line 63 returns SecretsManagerSeatExpansionFailed → 400. InviteAsync never runs.

Net result: +2 PM seats billed, 0 members invited, and the error message only mentions Secrets Manager. Retrying after the admin raises the SM cap buys 2 more PM seats on top, since ReserveSeatsAsync recomputes from occupancy that never moved.

The invite-dialog path added in this same PR does not have this hole — SaveUsersSendInvitesAsync unwinds the autoscale in its catch (OrganizationService.cs:729-733). The distinction that justifies keeping seats on a send failure (members were promoted, the admin can retry without paying twice) does not hold here, because nothing was promoted.

Rolling the PM reservation back before returning keeps billing consistent with the all-or-nothing result this path already advertises:

var secretsManagerSeatReservationError = await ReserveSecretsManagerSeatsAsync(organization, eligible);
if (secretsManagerSeatReservationError is not null)
{
    await ReleaseSeatsAsync(organization, reservedSeats);
    return secretsManagerSeatReservationError;
}

Alternatively, reserving SM seats only after InviteAsync succeeds would at least mean the seats that were bought correspond to members who were actually invited.

@@ -0,0 +1,19 @@
CREATE PROCEDURE [dbo].[OrganizationUser_ReadByOrganizationIdEmails]

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 the stored proc name should probably be OrganizationUser_ReadManyByOrganizationIdEmails

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants