Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d1bc436
[PM-46145] feat: Provision Privileged Controls on sales-assisted sign…
cyprain-okeke Nov 5, 2026
3e8906d
[PM-46145] feat: Make Admin Portal Privileged Controls available with…
cyprain-okeke Nov 5, 2026
74566a7
[PM-46145] feat: Enforce Privileged Controls seat guardrails without …
cyprain-okeke Nov 5, 2026
4ec2abd
[PM-46145] feat: Verify the sales-assisted token before provisioning …
cyprain-okeke Nov 5, 2026
845f13e
[PM-46145] refactor: Describe the sign-up purchase gate accurately an…
cyprain-okeke Nov 5, 2026
e197bcf
Merge branch 'main' into billing/pm-46145/sales-assisted-only-ga
cyprain-okeke Nov 5, 2026
e66ca1b
[PM-46145] fix: Let early-access organizations save in the Admin Portal
cyprain-okeke Nov 5, 2026
ffbffb6
[PM-46145] fix: Reject a sales-assisted sign-up whose invitation toke…
cyprain-okeke Nov 5, 2026
8b03a4b
[PM-46145] fix: Keep PAM values on provider-managed organizations whe…
cyprain-okeke Nov 5, 2026
ad5be20
[PM-46145] fix: Warn when the Privileged Controls Stripe check fails …
cyprain-okeke Nov 5, 2026
a0d015c
[PM-46145] refactor: Tidy Privileged Controls tests and make comments…
cyprain-okeke Nov 5, 2026
94c3c4e
[PM-46145] refactor: Avoid a second Stripe outage toast on the Admin …
cyprain-okeke Nov 5, 2026
6487c53
[PM-46145] fix: Turn Privileged Access Management off for provider-ma…
cyprain-okeke Nov 5, 2026
7132fcc
Merge branch 'main' into billing/pm-46145/sales-assisted-only-ga
cyprain-okeke Nov 5, 2026
d9154a5
[PM-46145] feat: Put Privileged Controls on the organization only for…
cyprain-okeke Nov 6, 2026
9abe7a8
Merge branch 'main' into billing/pm-46145/sales-assisted-only-ga
cyprain-okeke Nov 6, 2026
62c974a
[PM-46145] revert: Keep sign-up ignoring Privileged Controls while th…
cyprain-okeke Nov 6, 2026
889021e
[PM-46145] revert: Keep the Password Manager seat guardrails behind t…
cyprain-okeke Nov 6, 2026
1d63ff8
[PM-46145] fix: Admin Portal Privileged Access Management edits per r…
cyprain-okeke Nov 6, 2026
0e49e67
[PM-46145] feat: Make Privileged Controls seats a hard limit when gra…
cyprain-okeke Nov 6, 2026
53c9537
[PM-46145] fix: Always store the Privileged Access Management autosca…
cyprain-okeke Nov 6, 2026
6ffcfe5
Merge branch 'main' into billing/pm-46145/sales-assisted-only-ga
cyprain-okeke Nov 6, 2026
48d3700
[PM-46145] test: Expect a 400 when cloud Privileged Controls seats ar…
cyprain-okeke Nov 6, 2026
77abc50
Merge branch 'main' into billing/pm-46145/sales-assisted-only-ga
cyprain-okeke Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 55 additions & 26 deletions src/Admin/AdminConsole/Controllers/OrganizationsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,13 @@ public async Task<IActionResult> Edit(Guid id)
Trial = await GetTrialAsync(organization),
};

if (_featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support) && provider == null)
if (provider == null)
Comment thread
amorask-bitwarden marked this conversation as resolved.
Resolved
{
model.PrivilegedControlsStripeSeats = await GetPrivilegedControlsStripeSeatsAsync(organization);
if (_featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support))
{
model.PrivilegedControlsStripeSeats = await GetPrivilegedControlsStripeSeatsAsync(organization);
}

model.OccupiedPamSeatsCount =
await _organizationUserRepository.GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id);
}
Expand Down Expand Up @@ -452,7 +456,8 @@ public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
PlanType = organization.PlanType,
Seats = organization.Seats,
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation,
Enabled = organization.Enabled
Enabled = organization.Enabled,
UsePam = organization.UsePam
};

if (model.PlanType.HasValue)
Expand Down Expand Up @@ -480,17 +485,23 @@ public async Task<IActionResult> Edit(Guid id, OrganizationEditModel model)
}
}

UpdateOrganization(organization, model);
var isProviderManaged = await _providerRepository.GetByOrganizationIdAsync(organization.Id) != null;
UpdateOrganization(organization, model, isProviderManaged);
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
if (organization.UseSecretsManager && !plan.SupportsSecretsManager)
{
TempData["Error"] = "Plan does not support Secrets Manager";
return RedirectToAction("Edit", new { id });
}

if (_featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support)
&& _accessControlService.UserHasPermission(Permission.Org_Plan_Edit)
&& await ValidatePrivilegedControlsAsync(organization, plan) is { } privilegedControlsError)
if (_accessControlService.UserHasPermission(Permission.Org_Plan_Edit)
&& !isProviderManaged
&& ValidatePrivilegedControls(
organization,
plan,
organization.UsePam
? await _organizationUserRepository.GetOccupiedPamSeatCountByOrganizationIdAsync(organization.Id)
: 0) is { } privilegedControlsError)
{
TempData["Error"] = privilegedControlsError;
return RedirectToAction("Edit", new { id });
Expand All @@ -514,6 +525,12 @@ await HandlePotentialProviderSeatScalingAsync(
existingOrganizationData,
model);

if (isProviderManaged && existingOrganizationData.UsePam)
{
TempData["Warning"] =
"Privileged Access Management was turned off because organizations managed by a provider can't use it.";
}

await _organizationRepository.ReplaceAsync(organization);

if (cohortResolution.ShouldWrite)
Expand Down Expand Up @@ -856,18 +873,16 @@ private async Task<MigrationCohortAssignmentChange> ResolveMigrationCohortAssign
return new MigrationCohortAssignmentChange(assignmentToReplace, cohortToAssign, true, null);
}

private async Task<string> ValidatePrivilegedControlsAsync(Organization organization, Bit.Core.Models.StaticStore.Plan plan)
private static string ValidatePrivilegedControls(
Organization organization,
Bit.Core.Models.StaticStore.Plan plan,
int occupiedPamSeats)
{
if (!organization.UsePam)
{
return null;
}

if (await _providerRepository.GetByOrganizationIdAsync(organization.Id) != null)
{
return "Organizations managed by a provider can't use Privileged Access Management";
}

if (!plan.SupportsPrivilegedControls)
{
return "Plan does not support Privileged Access Management";
Expand All @@ -878,9 +893,9 @@ private async Task<string> ValidatePrivilegedControlsAsync(Organization organiza
return "Privileged Access Management seats must be at least 1";
}

if (organization.MaxAutoscalePamSeats < organization.PamSeats)
if (organization.PamSeats < occupiedPamSeats)
{
return "Privileged Access Management max autoscale seats can't be lower than seats";
return $"Privileged Access Management seats can't be lower than the {occupiedPamSeats} members who have access";
}

if (organization.PamSeats > organization.Seats)
Expand All @@ -889,16 +904,35 @@ private async Task<string> ValidatePrivilegedControlsAsync(Organization organiza
$"This organization has {organization.PamSeats} Privileged Controls seats and {organization.Seats} Password Manager seats.";
}

if (!BillingExtensions.PrivilegedControlsAutoscaleLimitFits(organization.MaxAutoscalePamSeats, organization.MaxAutoscaleSeats))
return null;
}

private void UpdatePrivilegedControls(Organization organization, OrganizationEditModel model, bool isProviderManaged)
{
// Organizations managed by a provider can't use Privileged Access Management, so a save turns it off.
if (isProviderManaged)
{
return "You cannot set a Privileged Controls autoscale limit higher than the Password Manager autoscale limit. " +
$"This organization has a Privileged Controls limit of {organization.MaxAutoscalePamSeats} and a Password Manager limit of {organization.MaxAutoscaleSeats}.";
organization.UsePam = false;
organization.PamSeats = null;
organization.MaxAutoscalePamSeats = null;
return;
}

return null;
organization.UsePam = model.UsePam;

// Autoscaling is self-serve, so the Admin Portal never stores a limit.
organization.MaxAutoscalePamSeats = null;

// Turning it off keeps the seat count, so it's still there if it's turned back on.
if (!model.UsePam)
{
return;
}

organization.PamSeats = model.PamSeats;
}

private void UpdateOrganization(Organization organization, OrganizationEditModel model)
private void UpdateOrganization(Organization organization, OrganizationEditModel model, bool isProviderManaged)
{
if (_accessControlService.UserHasPermission(Permission.Org_Name_Edit))
{
Expand Down Expand Up @@ -943,19 +977,14 @@ private void UpdateOrganization(Organization organization, OrganizationEditModel
organization.UsePhishingBlocker = model.UsePhishingBlocker;
organization.UseMyItems = model.UseMyItems;
organization.UseInviteLinks = model.UseInviteLinks;
organization.UsePam = model.UsePam;

//secrets
organization.SmSeats = model.SmSeats;
organization.MaxAutoscaleSmSeats = model.MaxAutoscaleSmSeats;
organization.SmServiceAccounts = model.SmServiceAccounts;
organization.MaxAutoscaleSmServiceAccounts = model.MaxAutoscaleSmServiceAccounts;

if (_featureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support))
{
organization.PamSeats = model.UsePam ? model.PamSeats : null;
organization.MaxAutoscalePamSeats = model.UsePam ? model.MaxAutoscalePamSeats : null;
}
UpdatePrivilegedControls(organization, model, isProviderManaged);
}

if (_accessControlService.UserHasPermission(Permission.Org_Licensing_Edit))
Expand Down
17 changes: 5 additions & 12 deletions src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@
var canManagePlanMigrationCohorts = AccessControlService.UserHasPermission(Permission.Tools_ManagePlanMigrationCohorts);
var businessPlanPriceMigrationEnabled = FeatureService.IsEnabled(FeatureFlagKeys.PM35215_BusinessPlanPriceMigration);
var canViewMigrationCohortAssignment = businessPlanPriceMigrationEnabled && canManagePlanMigrationCohorts;
var pamBillingSupportEnabled = FeatureService.IsEnabled(FeatureFlagKeys.PM39883_Pam_Billing_Support);
var showPamCheckbox = !pamBillingSupportEnabled || Model.Provider == null;
var showPamConfiguration = pamBillingSupportEnabled && Model.Provider == null;
// Provider-managed organizations don't get the PAM section; the controller turns PAM off for them on save.
var showPam = Model.Provider == null;
}

<form method="post" id="edit-form" asp-route-providerId="@Model.Provider?.Id">
Expand Down Expand Up @@ -261,15 +260,15 @@
<label class="form-check-label" asp-for="UseRiskInsights"></label>
</div>
</div>
@if (showPamCheckbox)
@if (showPam)
{
<div class="col-6">
<h3>Privileged Access Management</h3>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UsePam" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UsePam"></label>
</div>
@if (showPamConfiguration && !Model.UsePam && Model.PrivilegedControlsStripeSeats.HasValue)
@if (!Model.UsePam && Model.PrivilegedControlsStripeSeats.HasValue)
{
<div class="alert alert-warning mt-2"><strong>Privileged Access Management doesn't match Stripe.</strong> Privileged Access Management is off, but the Stripe subscription has a Privileged Access Management line for @Model.PrivilegedControlsStripeSeats seats.</div>
}
Expand Down Expand Up @@ -348,7 +347,7 @@
</div>
}

@if (canViewPlan && showPamConfiguration)
@if (canViewPlan && showPam)
{
<div id="organization-pam-configuration" hidden="@(!Model.UsePam)">
<h2>Privileged Access Management Configuration</h2>
Expand All @@ -363,12 +362,6 @@
<input type="number" class="form-control" asp-for="PamSeats" min="1" readonly='@(!canEditPlan)'>
</div>
</div>
<div class="col-sm">
<div class="mb-3">
<label class="form-label" asp-for="MaxAutoscalePamSeats"></label>
<input type="number" class="form-control" asp-for="MaxAutoscalePamSeats" min="1" readonly='@(!canEditPlan)'>
</div>
</div>
</div>
</div>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,10 @@

pamConfiguration.hidden = !event.target.checked;

// Unticking keeps the values: the server leaves the seat count and limit alone while PAM is off.
if (event.target.checked) {
setInitialPamConfiguration();
return;
}

clearPamConfiguration();
});
})();

Expand Down Expand Up @@ -158,13 +156,11 @@
}

function setInitialPamConfiguration() {
document.getElementById('@(nameof(Model.PamSeats))').value = Math.max(@Model.OccupiedPamSeatsCount, 1);
document.getElementById('@(nameof(Model.MaxAutoscalePamSeats))').value = '';
}

function clearPamConfiguration() {
document.getElementById('@(nameof(Model.PamSeats))').value = '';
document.getElementById('@(nameof(Model.MaxAutoscalePamSeats))').value = '';
// Keep a seat count saved from when PAM was last on; otherwise default to the members who have access.
const pamSeats = document.getElementById('@(nameof(Model.PamSeats))');
if (pamSeats.value === '') {
pamSeats.value = Math.max(@Model.OccupiedPamSeatsCount, 1);
}
}

function getPlan(planType) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ public async Task<CommandResult> BulkEnablePamAsync(Organization organization, I

var additionalPamSeatsRequired =
await countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(organization, organizationUsers.Count);
if (additionalPamSeatsRequired > 0 && globalSettings.SelfHosted)
if (additionalPamSeatsRequired > 0)
{
return new CannotAutoscalePamSeatsOnSelfHost();
return globalSettings.SelfHosted
? new CannotAutoscalePamSeatsOnSelfHost()
: new NotEnoughPrivilegedControlsSeats();
}

foreach (var organizationUser in organizationUsers)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,24 @@

using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.UpdateUser.v2;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements.Errors;
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;

namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;

Expand All @@ -33,7 +37,9 @@ public class RestoreOrganizationUserCommand(
ICollectionRepository collectionRepository,
IAutomaticUserConfirmationPolicyEnforcementHandler automaticUserConfirmationPolicyEnforcementHandler,
IDeleteEmergencyAccessCommand deleteEmergencyAccessCommand,
TimeProvider timeProvider) : IRestoreOrganizationUserCommand
TimeProvider timeProvider,
ICountNewPamSeatsRequiredQuery countNewPamSeatsRequiredQuery,
IGlobalSettings globalSettings) : IRestoreOrganizationUserCommand
{
public async Task RestoreUserAsync(OrganizationUser organizationUser, Guid? restoringUserId, string defaultCollectionName)
{
Expand Down Expand Up @@ -83,6 +89,13 @@ private async Task RepositoryRestoreUserAsync(OrganizationUser organizationUser,
}

var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId);

if (organizationUser.AccessPam &&
await GetPrivilegedControlsSeatLimitErrorAsync(organization, 1) is { } privilegedControlsSeatLimitError)
{
throw new BadRequestException(privilegedControlsSeatLimitError.Message);
}

var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total;

Expand Down Expand Up @@ -130,6 +143,31 @@ await collectionRepository.CreateDefaultCollectionsAsync(organizationUser.Organi
}
}

/// <summary>
/// The error to fail with when restoring <paramref name="membersWithAccess"/> members who have Privileged Controls
/// access would go over the organization's Privileged Controls seats, otherwise null.
/// </summary>
private async Task<BadRequestError> GetPrivilegedControlsSeatLimitErrorAsync(
Organization organization,
int membersWithAccess)
{
if (!organization.UsePam || membersWithAccess == 0)
{
return null;
}

var additionalPamSeatsRequired =
await countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(organization, membersWithAccess);
if (additionalPamSeatsRequired == 0)
{
return null;
}

return globalSettings.SelfHosted
? new CannotAutoscalePamSeatsOnSelfHost()
: new NotEnoughPrivilegedControlsSeats();
}

private async Task CheckUserForOtherFreeOrganizationOwnershipAsync(OrganizationUser organizationUser)
{
var relatedOrgUsersFromOtherOrgs = await organizationUserRepository.GetManyByUserAsync(organizationUser.UserId!.Value);
Expand Down Expand Up @@ -197,6 +235,12 @@ public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid
var newSeatsRequired = organizationUserIds.Count() - availableSeats;
await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired);

// Privileged Controls seats are a hard limit, so if the batch would go over them, none of its members with
// access are restored.
var privilegedControlsSeatLimitError = await GetPrivilegedControlsSeatLimitErrorAsync(
organization,
filteredUsers.Count(ou => ou.Status == OrganizationUserStatusType.Revoked && ou.AccessPam));

var restoringUserIsOwner = false;
var restoringUserIsAdminOrHigher = false;
if (restoringUserId.HasValue)
Expand Down Expand Up @@ -245,6 +289,11 @@ public async Task<List<Tuple<OrganizationUser, string>>> RestoreUsersAsync(Guid
.FirstOrDefault(ou => ou.userId == organizationUser.UserId.Value)
.twoFactorIsEnabled;

if (organizationUser.AccessPam && privilegedControlsSeatLimitError is not null)
{
throw new BadRequestException(privilegedControlsSeatLimitError.Message);
}

await CheckPoliciesBeforeRestoreAsync(organizationUser, twoFactorIsEnabled);

if (organization.PlanType == PlanType.Free)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public record CustomPermissionsNotEnabled() : BadRequestError("To enable custom
public record CannotAssignDefaultCollection() : BadRequestError("Default collections cannot be assigned to a member.");
public record CannotAutoscaleSecretsManagerSeatsOnSelfHost() : BadRequestError("Cannot autoscale on a self-hosted instance.");
public record CannotAutoscalePamSeatsOnSelfHost() : BadRequestError("Cannot autoscale Privileged Controls seats on a self-hosted instance.");
public record NotEnoughPrivilegedControlsSeats() : BadRequestError("Your organization has no Privileged Controls seats available. Contact sales for more seats.");
public record PamNotEnabled() : BadRequestError("To grant PAM access the organization must have PAM enabled.");
public record CouldNotIncreaseSeatsOfSecretManager(string Message) : BadRequestError(Message);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,11 @@ await mailer.SendEmail(new MemberEmailChangedNotificationMail
{
var additionalPamSeatsRequired =
await countNewPamSeatsRequiredQuery.CountNewPamSeatsRequiredAsync(request.Organization, 1);
// Only self-hosted instances are limited here: they cannot change their subscription. Cloud grants are not seat-limited by this command.
if (additionalPamSeatsRequired > 0 && globalSettings.SelfHosted)
if (additionalPamSeatsRequired > 0)
{
return new CannotAutoscalePamSeatsOnSelfHost();
return globalSettings.SelfHosted
? new CannotAutoscalePamSeatsOnSelfHost()
: new NotEnoughPrivilegedControlsSeats();
}

return null;
Expand Down
Loading
Loading