Skip to content

Commit b5dadcd

Browse files
Merge branch 'main' into auth/pm-27084/register-accepts-new-data-types
2 parents a5890d2 + 2442d2d commit b5dadcd

File tree

12 files changed

+968
-166
lines changed

12 files changed

+968
-166
lines changed

bitwarden_license/src/Sso/Controllers/AccountController.cs

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -680,22 +680,10 @@ await _organizationService.AdjustSeatsAsync(organization.Id,
680680
ApiKey = CoreHelpers.SecureRandomString(30)
681681
};
682682

683-
/*
684-
The feature flag is checked here so that we can send the new MJML welcome email templates.
685-
The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability
686-
to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need
687-
to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email.
688-
[PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users.
689-
TODO: Remove Feature flag: PM-28221
690-
*/
691-
if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates))
692-
{
693-
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
694-
}
695-
else
696-
{
697-
await _registerUserCommand.RegisterUser(newUser);
698-
}
683+
// Always use RegisterSSOAutoProvisionedUserAsync to ensure organization context is available
684+
// for domain validation (BlockClaimedDomainAccountCreation policy) and welcome emails.
685+
// The feature flag logic for welcome email templates is handled internally by RegisterUserCommand.
686+
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
699687

700688
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
701689
var twoFactorPolicy =

bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs

Lines changed: 0 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using Bit.Core.Auth.Models.Business.Tokenables;
77
using Bit.Core.Auth.Models.Data;
88
using Bit.Core.Auth.Repositories;
9-
using Bit.Core.Auth.UserFeatures.Registration;
109
using Bit.Core.Entities;
1110
using Bit.Core.Enums;
1211
using Bit.Core.Repositories;
@@ -21,7 +20,6 @@
2120
using Duende.IdentityServer.Services;
2221
using Microsoft.AspNetCore.Authentication;
2322
using Microsoft.AspNetCore.Http;
24-
using Microsoft.AspNetCore.Identity;
2523
using Microsoft.AspNetCore.Mvc;
2624
using Microsoft.Extensions.DependencyInjection;
2725
using NSubstitute;
@@ -1013,133 +1011,6 @@ public async Task ExternalCallback_Measurements_FlagOnVsOff_Comparisons(
10131011
}
10141012
}
10151013

1016-
[Theory, BitAutoData]
1017-
public async Task AutoProvisionUserAsync_WithFeatureFlagEnabled_CallsRegisterSSOAutoProvisionedUser(
1018-
SutProvider<AccountController> sutProvider)
1019-
{
1020-
// Arrange
1021-
var orgId = Guid.NewGuid();
1022-
var providerUserId = "ext-new-user";
1023-
var email = "[email protected]";
1024-
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
1025-
1026-
// No existing user (JIT provisioning scenario)
1027-
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
1028-
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
1029-
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
1030-
.Returns((OrganizationUser?)null);
1031-
1032-
// Feature flag enabled
1033-
sutProvider.GetDependency<IFeatureService>()
1034-
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
1035-
.Returns(true);
1036-
1037-
// Mock the RegisterSSOAutoProvisionedUserAsync to return success
1038-
sutProvider.GetDependency<IRegisterUserCommand>()
1039-
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>())
1040-
.Returns(IdentityResult.Success);
1041-
1042-
var claims = new[]
1043-
{
1044-
new Claim(JwtClaimTypes.Email, email),
1045-
new Claim(JwtClaimTypes.Name, "New User")
1046-
} as IEnumerable<Claim>;
1047-
var config = new SsoConfigurationData();
1048-
1049-
var method = typeof(AccountController).GetMethod(
1050-
"CreateUserAndOrgUserConditionallyAsync",
1051-
BindingFlags.Instance | BindingFlags.NonPublic);
1052-
Assert.NotNull(method);
1053-
1054-
// Act
1055-
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
1056-
sutProvider.Sut,
1057-
new object[]
1058-
{
1059-
orgId.ToString(),
1060-
providerUserId,
1061-
claims,
1062-
null!,
1063-
config
1064-
})!;
1065-
1066-
var result = await task;
1067-
1068-
// Assert
1069-
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
1070-
.RegisterSSOAutoProvisionedUserAsync(
1071-
Arg.Is<User>(u => u.Email == email && u.Name == "New User"),
1072-
Arg.Is<Organization>(o => o.Id == orgId && o.Name == "Test Org"));
1073-
1074-
Assert.NotNull(result.user);
1075-
Assert.Equal(email, result.user.Email);
1076-
Assert.Equal(organization.Id, result.organization.Id);
1077-
}
1078-
1079-
[Theory, BitAutoData]
1080-
public async Task AutoProvisionUserAsync_WithFeatureFlagDisabled_CallsRegisterUserInstead(
1081-
SutProvider<AccountController> sutProvider)
1082-
{
1083-
// Arrange
1084-
var orgId = Guid.NewGuid();
1085-
var providerUserId = "ext-legacy-user";
1086-
var email = "[email protected]";
1087-
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
1088-
1089-
// No existing user (JIT provisioning scenario)
1090-
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
1091-
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
1092-
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
1093-
.Returns((OrganizationUser?)null);
1094-
1095-
// Feature flag disabled
1096-
sutProvider.GetDependency<IFeatureService>()
1097-
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
1098-
.Returns(false);
1099-
1100-
// Mock the RegisterUser to return success
1101-
sutProvider.GetDependency<IRegisterUserCommand>()
1102-
.RegisterUser(Arg.Any<User>())
1103-
.Returns(IdentityResult.Success);
1104-
1105-
var claims = new[]
1106-
{
1107-
new Claim(JwtClaimTypes.Email, email),
1108-
new Claim(JwtClaimTypes.Name, "Legacy User")
1109-
} as IEnumerable<Claim>;
1110-
var config = new SsoConfigurationData();
1111-
1112-
var method = typeof(AccountController).GetMethod(
1113-
"CreateUserAndOrgUserConditionallyAsync",
1114-
BindingFlags.Instance | BindingFlags.NonPublic);
1115-
Assert.NotNull(method);
1116-
1117-
// Act
1118-
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
1119-
sutProvider.Sut,
1120-
new object[]
1121-
{
1122-
orgId.ToString(),
1123-
providerUserId,
1124-
claims,
1125-
null!,
1126-
config
1127-
})!;
1128-
1129-
var result = await task;
1130-
1131-
// Assert
1132-
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
1133-
.RegisterUser(Arg.Is<User>(u => u.Email == email && u.Name == "Legacy User"));
1134-
1135-
// Verify the new method was NOT called
1136-
await sutProvider.GetDependency<IRegisterUserCommand>().DidNotReceive()
1137-
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>());
1138-
1139-
Assert.NotNull(result.user);
1140-
Assert.Equal(email, result.user.Email);
1141-
}
1142-
11431014
[Theory, BitAutoData]
11441015
public void ExternalChallenge_WithMatchingOrgId_Succeeds(
11451016
SutProvider<AccountController> sutProvider,

src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Bit.Api.Billing.Attributes;
22
using Bit.Api.Billing.Models.Requests.Payment;
33
using Bit.Api.Billing.Models.Requests.Premium;
4+
using Bit.Api.Billing.Models.Requests.Storage;
45
using Bit.Core;
56
using Bit.Core.Billing.Licenses.Queries;
67
using Bit.Core.Billing.Payment.Commands;
@@ -23,7 +24,8 @@ public class AccountBillingVNextController(
2324
IGetCreditQuery getCreditQuery,
2425
IGetPaymentMethodQuery getPaymentMethodQuery,
2526
IGetUserLicenseQuery getUserLicenseQuery,
26-
IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
27+
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
28+
IUpdatePremiumStorageCommand updatePremiumStorageCommand) : BaseBillingController
2729
{
2830
[HttpGet("credit")]
2931
[InjectUser]
@@ -88,4 +90,15 @@ public async Task<IResult> GetLicenseAsync(
8890
var response = await getUserLicenseQuery.Run(user);
8991
return TypedResults.Ok(response);
9092
}
93+
94+
[HttpPut("storage")]
95+
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
96+
[InjectUser]
97+
public async Task<IResult> UpdateStorageAsync(
98+
[BindNever] User user,
99+
[FromBody] StorageUpdateRequest request)
100+
{
101+
var result = await updatePremiumStorageCommand.Run(user, request.AdditionalStorageGb);
102+
return Handle(result);
103+
}
91104
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System.ComponentModel.DataAnnotations;
2+
3+
namespace Bit.Api.Billing.Models.Requests.Storage;
4+
5+
/// <summary>
6+
/// Request model for updating storage allocation on a user's premium subscription.
7+
/// Allows for both increasing and decreasing storage in an idempotent manner.
8+
/// </summary>
9+
public class StorageUpdateRequest : IValidatableObject
10+
{
11+
/// <summary>
12+
/// The additional storage in GB beyond the base storage.
13+
/// Must be between 0 and the maximum allowed (minus base storage).
14+
/// </summary>
15+
[Required]
16+
[Range(0, 99)]
17+
public short AdditionalStorageGb { get; set; }
18+
19+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
20+
{
21+
if (AdditionalStorageGb < 0)
22+
{
23+
yield return new ValidationResult(
24+
"Additional storage cannot be negative.",
25+
new[] { nameof(AdditionalStorageGb) });
26+
}
27+
28+
if (AdditionalStorageGb > 99)
29+
{
30+
yield return new ValidationResult(
31+
"Maximum additional storage is 99 GB.",
32+
new[] { nameof(AdditionalStorageGb) });
33+
}
34+
}
35+
}

src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs renamed to src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs

File renamed without changes.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Organization Ability Flags
2+
3+
## Overview
4+
5+
Many Bitwarden features are tied to specific subscription plans. For example, SCIM and SSO are Enterprise features,
6+
while Event Logs are available to Teams and Enterprise plans. When developing features that require plan-based access
7+
control, we use **Organization Ability Flags** (or simply _abilities_) — explicit boolean properties on the Organization
8+
entity that indicate whether an organization can use a specific feature.
9+
10+
## The Rule
11+
12+
**Never check plan types to control feature access.** Always use a dedicated ability flag on the Organization entity.
13+
14+
### ❌ Don't Do This
15+
16+
```csharp
17+
// Checking plan type directly
18+
if (organization.PlanType == PlanType.Enterprise ||
19+
organization.PlanType == PlanType.Teams ||
20+
organization.PlanType == PlanType.Family)
21+
{
22+
// allow feature...
23+
}
24+
```
25+
26+
### ❌ Don't Do This
27+
28+
```csharp
29+
// Piggybacking off another feature's ability
30+
if (organization.PlanType == PlanType.Enterprise && organization.UseEvents)
31+
{
32+
// assume they can use some other feature...
33+
}
34+
```
35+
36+
### ✅ Do This Instead
37+
38+
```csharp
39+
// Check the explicit ability flag
40+
if (organization.UseEvents)
41+
{
42+
// allow UseEvents feature...
43+
}
44+
```
45+
46+
## Why This Pattern Matters
47+
48+
Using explicit ability flags instead of plan type checks provides several benefits:
49+
50+
1. **Simplicity** — A single boolean check is cleaner and less error-prone than maintaining lists of plan types.
51+
52+
2. **Centralized Control** — Feature access is managed in one place: the ability assignment during organization
53+
creation/upgrade. No need to hunt through the codebase for scattered plan type checks.
54+
55+
3. **Flexibility** — Abilities can be set independently of plan type, enabling:
56+
57+
- Early access programs for features not yet tied to a plan
58+
- Trial access to help customers evaluate a feature before upgrading
59+
- Custom arrangements for specific customers
60+
- A/B testing of features across different cohorts
61+
62+
4. **Safe Refactoring** — When plans change (e.g., adding a new plan tier, renaming plans, or moving features between
63+
tiers), we only update the ability assignment logic—not every place the feature is used.
64+
65+
5. **Graceful Downgrades** — When an organization downgrades, we update their abilities. All feature checks
66+
automatically respect the new access level.
67+
68+
## How It Works
69+
70+
### Ability Assignment at Signup/Upgrade
71+
72+
When an organization is created or changes plans, the ability flags are set based on the plan's capabilities:
73+
74+
```csharp
75+
// During organization creation or plan change
76+
organization.UseGroups = plan.HasGroups;
77+
organization.UseSso = plan.HasSso;
78+
organization.UseScim = plan.HasScim;
79+
organization.UsePolicies = plan.HasPolicies;
80+
organization.UseEvents = plan.HasEvents;
81+
// ... etc
82+
```
83+
84+
### Modifying Abilities for Existing Organizations
85+
86+
To change abilities for existing organizations (e.g., rolling out a feature to a new plan tier), create a database
87+
migration that updates the relevant flag:
88+
89+
```sql
90+
-- Example: Enable UseEvents for all Teams organizations
91+
UPDATE [dbo].[Organization]
92+
SET UseEvents = 1
93+
WHERE PlanType IN (17, 18) -- TeamsMonthly = 17, TeamsAnnually = 18
94+
```
95+
96+
Then update the plan-to-ability assignment code so new organizations get the correct value.
97+
98+
## Adding a New Ability
99+
100+
When developing a new plan-gated feature:
101+
102+
1. **Add the ability to the Organization and OrganizationAbility entities** — Create a `Use[FeatureName]` boolean
103+
property.
104+
105+
2. **Add a database migration** — Add the new column to the Organization table.
106+
107+
3. **Update plan definitions** — Add a corresponding `Has[FeatureName]` property to the Plan model and configure which
108+
plans include it.
109+
110+
4. **Update organization creation/upgrade logic** — Ensure the ability is set based on the plan.
111+
112+
5. **Update the organization license claims** (if applicable) - to make the feature available on self-hosted instances.
113+
114+
6. **Implement checks throughout client and server** — Use the ability consistently everywhere the feature is accessed.
115+
- Clients: get the organization object from `OrganizationService`.
116+
- Server: if you already have the full `Organization` object in scope, you can use it directly. If not, use the
117+
`IApplicationCacheService` to retrieve the `OrganizationAbility`, which is a simplified, cached representation
118+
of the organization ability flags. Note that some older flags may be missing from `OrganizationAbility` but
119+
can be added if needed.
120+
121+
## Existing Abilities
122+
123+
For reference, here are some current organization ability flags (not a complete list):
124+
125+
| Ability | Description | Plans |
126+
|--------------------------|-------------------------------|-------------------|
127+
| `UseGroups` | Group-based collection access | Teams, Enterprise |
128+
| `UseDirectory` | Directory Connector sync | Teams, Enterprise |
129+
| `UseEvents` | Event logging | Teams, Enterprise |
130+
| `UseTotp` | Authenticator (TOTP) | Teams, Enterprise |
131+
| `UseSso` | Single Sign-On | Enterprise |
132+
| `UseScim` | SCIM provisioning | Teams, Enterprise |
133+
| `UsePolicies` | Enterprise policies | Enterprise |
134+
| `UseResetPassword` | Admin password reset | Enterprise |
135+
| `UseOrganizationDomains` | Domain verification/claiming | Enterprise |
136+
137+
## Questions?
138+
139+
If you're unsure whether your feature needs a new ability or which existing ability to use, reach out to your team lead
140+
or members of the Admin Console or Architecture teams. When in doubt, adding an explicit ability is almost always the
141+
right choice—it's easy to do and keeps our access control clean and maintainable.

src/Core/Billing/Extensions/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ private static void AddPremiumCommands(this IServiceCollection services)
5353
services.AddScoped<ICreatePremiumCloudHostedSubscriptionCommand, CreatePremiumCloudHostedSubscriptionCommand>();
5454
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
5555
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
56+
services.AddScoped<IUpdatePremiumStorageCommand, UpdatePremiumStorageCommand>();
5657
}
5758

5859
private static void AddPremiumQueries(this IServiceCollection services)

0 commit comments

Comments
 (0)