Skip to content

[PM-46145] feat: Keep Privileged Controls sales-assisted while PAM billing support is off - #8820

Open
cyprain-okeke wants to merge 24 commits into
mainfrom
billing/pm-46145/sales-assisted-only-ga
Open

cyprain-okeke wants to merge 24 commits into
mainfrom
billing/pm-46145/sales-assisted-only-ga

Conversation

@cyprain-okeke

@cyprain-okeke cyprain-okeke commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-46145

📔 Objective

Privileged Controls goes GA sales-assisted only. The existing pm-39883-pam-billing-support flag is the self-serve switch and stays off at GA. Sales-assisted means staff turning Privileged Access Management on from the Admin Portal organization edit page; the platform treats every such organization as a sales-initiated trial, and Finance bills it outside the platform. The scope follows the rewritten PM-46145 acceptance criteria and the review on this PR.

With the flag off:

  • Admin Portal organization edit page is the only way an organization gets Privileged Access Management. The checkbox and seat count show, validate and save regardless of the flag. The autoscale limit is no longer shown and is always stored empty (autoscaling is self-serve). Unticking turns it off but keeps the seat count and members' access, so turning it back on shows the old count. The save is rejected, regardless of the flag, when the plan does not support it, seats are below one, seats are above Password Manager seats, or seats are below the number of members who already have access (new server check; only the form script enforced it before). An organization with it on and no seat count must have one entered on its next save. Provider-managed organizations never see the checkbox; a save turns it off with a warning to staff if it was on. The Stripe mismatch lookup stays behind the flag.
  • Sign-up behaves as on main: with the flag off it ignores a Privileged Controls request on every path, sales invitation included, and the sales-assisted trial invitation form does not offer it.
  • Self-serve purchase, seat change and removal endpoints return 404 (unchanged).
  • The Password Manager seat and autoscale guardrails stay behind the flag as on main.

Regardless of the flag:

  • Privileged Controls seats are a hard limit. Giving a member access, singly or in bulk, fails with a 400 and a "contact sales for more seats" message when it would put seats in use over the seat count, in cloud and self-hosted (self-hosted keeps its existing message). Restoring a revoked member who has access, singly or in bulk, fails the same way. Nothing autoscales and Stripe is not called. These changes are in Admin Console-owned files (UpdateOrganizationUserCommand, BulkEnablePamCommand, RestoreOrganizationUserCommand) and overlap Kyle's [PM-45107] feat: Autoscale Privileged Controls seats when members get access #8812, which replaces the seat-count query in the same three places; whichever lands second rebases.
  • A self-hosted license with fewer Privileged Controls seats than members with access is accepted: existing grants stay, new grants are blocked (pinned by a test).

Companion bitwarden/clients PR: bitwarden/clients#24476. It hides the self-serve subscription sections behind the flag, shows Contact sales blocks with seats in use out of total, and leaves Privileged Controls out of every trial sign-up while the flag is off.

QA: recorded end-to-end runs against local cloud + Stripe test mode with the flag off and on (Admin Portal including a legacy and a provider-managed organization, sales invitation, self-serve trial link, subscription page, gated endpoints, hard seat limit on grants and restores). Evidence in the ticket's QA notes.

🤖 AI-assisted review

Multi-agent local review before opening, a second pass on the open PR, and a third pass modelled on the Billing lead's review history. The lead's own review (request changes, 2026-11-06) then tightened the GA scope; every inline item and the body ask are applied in the commits from f36b6f7e9f onward. Earlier review findings about the sales invitation token, the Stripe-check warning and the legacy-organization exemption no longer apply because those paths were removed.

Deferred, recorded for the reviewer:

  1. The Admin Portal still binds MaxAutoscalePamSeats on the edit model although it is never rendered or stored; left to keep churn down.
  2. An organization with Privileged Access Management on and no seat count is not seat-limited for grants until staff next save it (the count query treats a missing seat count as unlimited, which older self-hosted licenses rely on); that save now forces a seat count.
  3. Bulk restore is all-or-nothing for the members with access in the batch when the limit would be exceeded; Password Manager seats are still auto-added for the whole batch before the per-member checks, as they are for the other per-member failures today.

@cyprain-okeke cyprain-okeke added the t:feature Change Type - Feature Development label Nov 5, 2026
Organizations given Privileged Access Management before seats existed are
stored with UsePam on and no seat count. Now that the Admin Portal validates
the section regardless of the flag, every save of such an organization (even
a rename) was rejected with "seats must be at least 1".

When the stored organization already had UsePam on with no seats and the
save doesn't supply a seat count, skip the seats-required rule and keep the
seat count unset. The other rules still apply, and a posted seat count is
validated normally.
…n fails

With pm-39883-pam-billing-support off, a sign-up on the sales-assisted
initiation path that asks for Privileged Controls now fails with a
BadRequest when the sales invitation token is missing, blank, unreadable,
expired or issued to another email, instead of silently creating the
organization without Privileged Controls. The token error messages come from
the existing SalesAssistedRegistrationTokenable validator; neither the token
nor the email is logged.

Self-serve sign-ups with the flag off keep ignoring the request (the clients
hide those entry points). With the flag on, Privileged Controls provisions
whatever the token state.
…n saved in the Admin Portal

The organization form no longer renders the Privileged Access Management
section for provider-managed organizations, so the posted model always bound
UsePam = false and any save cleared UsePam, PamSeats and
MaxAutoscalePamSeats. The Edit POST now looks up the provider once and, for
provider-managed organizations, keeps the stored PAM values instead of the
posted ones and skips the PAM validation (nothing in that section can change).

Edit_PrivilegedControls_ProviderManaged_Rejects is removed: a provider-managed
organization's posted UsePam is now ignored rather than rejected, so the
validator's provider check became unreachable and was dropped with it. The
test is replaced by Edit_PrivilegedControls_ProviderManaged_PostedPamIgnored,
which pins that such an organization still can't gain PAM from the form, plus
Edit_PrivilegedControls_ProviderManaged_KeepsStoredValues for the round trip.
ValidatePrivilegedControlsAsync becomes the synchronous
ValidatePrivilegedControls since it no longer queries the provider.
…on the Admin edit page

A failed Stripe call while loading the Privileged Controls seat line was
logged and rendered exactly like "no mismatch". The edit page now shows a
warning that the check could not be loaded, mirroring the billing-info load
in the same action. The log still carries only the organization id. A
warning already set by the billing-info load is kept rather than replaced.
… describe the gate accurately

Tests:
- Admin OrganizationsControllerTests: keep one explicit flag-off test per
  verb (GET loads seats, POST saves seats); drop the flag-off validation
  test, which duplicated Edit_PrivilegedControls_PlanDoesNotSupport_Rejects.
- CreateOrganizationSubscriptionCommandTests: pin the literal sales-assisted
  initiation path to the "sales-assisted" trialInitiationPath Stripe metadata
  value, as OrganizationBillingServiceTests does for Finalize.
- CloudOrganizationSignUpCommandTests: replace the repeated 11-seat comments
  with a PamSeatsAbovePasswordManagerSeats constant and one helper comment.

Comments:
- Document PM39883_Pam_Billing_Support as the self-serve gate.
- OrganizationService guardrails: state why they aren't flag-gated and that
  the self-serve endpoints return 404 with the flag off.
- CloudOrganizationSignUpCommand: drop the stale purchase-branch
  parenthetical.
- OrganizationNoPaymentCreateRequest.SalesAssistedToken: describe the
  sales-path-only use and the rejection of a missing or invalid token.
- Subscriptions.Organization README: describe this library's own use of
  TrialInitiationConstants.
- TrialInitiationConstants: name the clients enum file it must match.
- Remove the unused Bit.Core using from the sales-assisted trial view.
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the Admin Portal organization edit changes, where Privileged Access Management now saves regardless of the flag, the autoscale limit is always stored as null, the seat count is kept on untick, and provider-managed organizations are forced off. Also reviewed the new server check that rejects a seat count below the occupied count, and the hard Privileged Controls seat limit in UpdateOrganizationUserCommand, BulkEnablePamCommand, and RestoreOrganizationUserCommand (single and bulk). The occupied-seat query excludes revoked members, so the restore checks do not double-count the member being restored, and the Password Manager guardrails in OrganizationService still require UsePam, so keeping PamSeats while PAM is off has no effect elsewhere. All eight prior threads are resolved and none of their issues come back in the current diff. No new findings.

@codecov

codecov Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.25000% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.50%. Comparing base (df91576) to head (77abc50).

Files with missing lines Patch % Lines
...AdminConsole/Views/Shared/_OrganizationForm.cshtml 0.00% 4 Missing ⚠️
...nsole/Views/Shared/_OrganizationFormScripts.cshtml 0.00% 2 Missing ⚠️
...s/RestoreUser/v1/RestoreOrganizationUserCommand.cs 96.15% 0 Missing and 1 partial ⚠️

❗ There is a different number of reports uploaded between BASE (df91576) and HEAD (77abc50). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (df91576) HEAD (77abc50)
2 1
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8820      +/-   ##
==========================================
- Coverage   74.83%   68.50%   -6.34%     
==========================================
  Files        2750     2750              
  Lines      117618   117661      +43     
  Branches    10781    10787       +6     
==========================================
- Hits        88022    80601    -7421     
- Misses      26940    34519    +7579     
+ Partials     2656     2541     -115     

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

@cyprain-okeke
cyprain-okeke marked this pull request as ready for review November 5, 2026 19:25
@cyprain-okeke
cyprain-okeke requested review from a team as code owners November 5, 2026 19:25
@amorask-bitwarden
amorask-bitwarden self-requested a review November 5, 2026 20:03

@amorask-bitwarden amorask-bitwarden 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.

We tightened the GA scope after this went up, so some of this needs to change direction. For GA:

  • The Admin Portal org edit page is the only way an org gets PAM. Staff set the checkbox and a seat count, and that's it.
  • Every PAM org is a sales-initiated trial as far as the platform is concerned. Finance bills PAM outside the platform, so we don't read or write any Stripe PAM line with the flag off.
  • Self-serve stays off behind the flag, as it is on main.

The inline comments cover the specific changes. The main ones are removing the sales invitation path, hiding the autoscale limit, keeping the Password Manager seat checks behind the flag, and adding a server check that PAM seats aren't below the number in use.

One thing that can't go inline because the files aren't in this diff: PAM seats need to be a hard limit. In cloud, giving a member PAM access (single or bulk) should fail with a 400 and a "contact sales for more seats" message when it would go over PamSeats, the same way self-hosted already does (here). Restoring a revoked member who has PAM access should fail the same way, in cloud and self-hosted. This applies whether the flag is on or off; autoscale can replace it later when self-serve comes back.

…e flag is off

With the flag off, the Admin Portal organization edit page is the only way
an organization gets Privileged Access Management. Restore main's sign-up
behaviour so sign-up ignores Privileged Controls, drop the sales invitation
token check and its request field, return the initiation path constant to
the billing services, and keep Privileged Controls behind the flag on the
sales invitation form.
…he flag

Members with Privileged Access Management already take up Password Manager
seats, so the seat and autoscale-limit guardrails only matter for self-serve.
Restore main's flag check on both so owners aren't blocked by a rule they
can't resolve without contacting sales.
…eview

- Only look up the Stripe Privileged Controls line with the flag on, and drop
  the failed-lookup warning; the occupied seat count still loads for the form.
- Require a seat count whenever Privileged Access Management is on, removing
  the exemption for organizations saved before seats existed.
- Reject a seat count below the number of members who already have access.
- Keep the seat count and autoscale limit when staff turn it off, so they're
  there if it's turned back on; members keep their access. Provider-managed
  organizations still clear both.
- Remove the autoscale limit from the form and always save it as empty, since
  autoscaling is self-serve; the two limit rules it fed are gone with it.
- The form script no longer clears or overwrites a saved seat count when the
  checkbox is toggled.
…nting or restoring access

Finance bills Privileged Controls outside the platform, so cloud no longer
lets a grant go over the organization's seats. Granting access to one member
or in bulk now fails with a 400 asking the organization to contact sales,
matching how self-hosted already refused. Restoring a revoked member who has
access is limited the same way in cloud and self-hosted; a bulk restore
reports the error against each member with access and restores the rest.

A self-hosted license with fewer seats than members who already have access
is still accepted, so existing grants stay and only new ones are blocked.
@cyprain-okeke

cyprain-okeke commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor Author

All applied, see the inline replies. The hard seat limit (grants and restores, cloud and self-hosted, flag on or off) uses the existing seat-count query so Kyle's #8812 can swap in his command on rebase. Those commands are Admin Console-owned, so I've requested an AC reviewer. Clients #24476 mirrors this.

@ike-kottlowski

Copy link
Copy Markdown
Contributor

There are no auth owned files in the change set. Removing Auth as reviewers.

@ike-kottlowski
ike-kottlowski removed request for a team and ike-kottlowski November 6, 2026 17:40

This branch has not been deployed

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

Labels

ai-review Request a Claude code review t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants