1
0
mirror of https://github.com/bitwarden/server synced 2025-12-19 17:53:44 +00:00

Merge branch 'main' into SM-1571-DisableSMAdsForUsers

This commit is contained in:
cd-bitwarden
2025-12-03 09:52:25 -05:00
committed by GitHub
456 changed files with 48313 additions and 3579 deletions

1
.github/CODEOWNERS vendored
View File

@@ -36,6 +36,7 @@ util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
# UIF
src/Core/MailTemplates/Mjml @bitwarden/team-ui-foundation # Teams are expected to own sub-directories of this project
src/Core/MailTemplates/Mjml/.mjmlconfig # This change allows teams to add components within their own subdirectories without requiring a code review from UIF.
# Auth team
**/Auth @bitwarden/team-auth-dev

View File

@@ -1,6 +1,6 @@
name: Bitwarden Unified Deployment Bug Report
name: Bitwarden lite Deployment Bug Report
description: File a bug report
labels: [bug, bw-unified-deploy]
labels: [bug, bw-lite-deploy]
body:
- type: markdown
attributes:
@@ -74,7 +74,7 @@ body:
id: epic-label
attributes:
label: Issue-Link
description: Link to our pinned issue, tracking all Bitwarden Unified
description: Link to our pinned issue, tracking all Bitwarden lite
value: |
https://github.com/bitwarden/server/issues/2480
validations:

View File

@@ -42,7 +42,7 @@
dependencyDashboardApproval: false,
},
{
matchSourceUrls: ["https://github.com/bitwarden/sdk-internal"],
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
groupName: "sdk-internal",
},
{
@@ -63,7 +63,6 @@
},
{
matchPackageNames: [
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"DuoUniversal",
"Fido2.AspNet",
"Duende.IdentityServer",
@@ -90,11 +89,7 @@
"Microsoft.AspNetCore.Mvc.Testing",
"Newtonsoft.Json",
"NSubstitute",
"Sentry.Serilog",
"Serilog.AspNetCore",
"Serilog.Extensions.Logging",
"Serilog.Extensions.Logging.File",
"Serilog.Sinks.SyslogMessages",
"Stripe.net",
"Swashbuckle.AspNetCore",
"Swashbuckle.AspNetCore.SwaggerGen",
@@ -141,6 +136,7 @@
"AspNetCoreRateLimit",
"AspNetCoreRateLimit.Redis",
"Azure.Data.Tables",
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"Azure.Messaging.EventGrid",
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",

View File

@@ -22,7 +22,7 @@ env:
jobs:
lint:
name: Lint
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -38,7 +38,7 @@ jobs:
build-artifacts:
name: Build Docker images
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
needs:
- lint
outputs:
@@ -46,6 +46,7 @@ jobs:
permissions:
security-events: write
id-token: write
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
@@ -122,7 +123,7 @@ jobs:
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
@@ -159,7 +160,7 @@ jobs:
ls -atlh ../../../
- name: Upload project artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: ${{ matrix.dotnet }}
with:
name: ${{ matrix.project_name }}.zip
@@ -184,13 +185,6 @@ jobs:
- name: Log in to ACR - production subscription
run: az acr login -n bitwardenprod
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
########## Generate image tag and build Docker image ##########
- name: Generate Docker image tag
id: tag
@@ -249,8 +243,6 @@ jobs:
linux/arm64
push: true
tags: ${{ steps.image-tags.outputs.tags }}
secrets: |
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
- name: Install Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@@ -279,7 +271,7 @@ jobs:
output-format: sarif
- name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
@@ -290,7 +282,7 @@ jobs:
upload:
name: Upload
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
needs: build-artifacts
permissions:
id-token: write
@@ -364,7 +356,7 @@ jobs:
if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: docker-stub-US.zip
path: docker-stub-US.zip
@@ -374,7 +366,7 @@ jobs:
if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: docker-stub-EU.zip
path: docker-stub-EU.zip
@@ -386,21 +378,21 @@ jobs:
pwsh ./generate_openapi_files.ps1
- name: Upload Public API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: swagger.json
path: api.public.json
if-no-files-found: error
- name: Upload Internal API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: internal.json
path: api.json
if-no-files-found: error
- name: Upload Identity Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: identity.json
path: identity.json
@@ -408,7 +400,7 @@ jobs:
build-mssqlmigratorutility:
name: Build MSSQL migrator utility
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
needs:
- lint
defaults:
@@ -446,7 +438,7 @@ jobs:
- name: Upload project artifact for Windows
if: ${{ contains(matrix.target, 'win') == true }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
@@ -454,7 +446,7 @@ jobs:
- name: Upload project artifact
if: ${{ contains(matrix.target, 'win') == false }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
@@ -465,7 +457,7 @@ jobs:
if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
needs:
- build-artifacts
permissions:
@@ -478,25 +470,34 @@ jobs:
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger self-host build
- name: Generate GH App token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
owner: ${{ github.repository_owner }}
repositories: self-host
- name: Trigger Bitwarden lite build
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
github-token: ${{ steps.app-token.outputs.token }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
repo: 'self-host',
workflow_id: 'build-unified.yml',
workflow_id: 'build-bitwarden-lite.yml',
ref: 'main',
inputs: {
server_branch: process.env.GITHUB_REF
@@ -519,20 +520,29 @@ jobs:
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
owner: ${{ github.repository_owner }}
repositories: devops
- name: Trigger k8s deploy
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
github-token: ${{ steps.app-token.outputs.token }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',

View File

@@ -15,6 +15,7 @@ jobs:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
actions: read
contents: read
id-token: write
pull-requests: write

View File

@@ -62,7 +62,7 @@ jobs:
docker compose --profile mssql --profile postgres --profile mysql up -d
shell: pwsh
- name: Add MariaDB for unified
- name: Add MariaDB for Bitwarden lite
# Use a different port than MySQL
run: |
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
@@ -133,7 +133,7 @@ jobs:
# Default Sqlite
BW_TEST_DATABASES__3__TYPE: "Sqlite"
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
# Unified MariaDB
# Bitwarden lite MariaDB
BW_TEST_DATABASES__4__TYPE: "MySql"
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
@@ -197,7 +197,7 @@ jobs:
shell: pwsh
- name: Upload DACPAC
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: sql.dacpac
path: Sql.dacpac
@@ -223,7 +223,7 @@ jobs:
shell: pwsh
- name: Report validation results
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: report.xml
path: |
@@ -262,3 +262,26 @@ jobs:
working-directory: "dev"
run: docker compose down
shell: pwsh
validate-migration-naming:
name: Validate new migration naming and order
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
- name: Validate new migrations for pull request
if: github.event_name == 'pull_request'
run: |
git fetch origin main:main
pwsh dev/verify_migrations.ps1 -BaseRef main
shell: pwsh
- name: Validate new migrations for push
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: pwsh dev/verify_migrations.ps1 -BaseRef HEAD~1
shell: pwsh

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.11.0</Version>
<Version>2025.12.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -35,8 +35,9 @@ public class ProviderService : IProviderService
{
private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [
PlanType.Free,
PlanType.FamiliesAnnually,
PlanType.FamiliesAnnually2019
PlanType.FamiliesAnnually2025,
PlanType.FamiliesAnnually2019,
PlanType.FamiliesAnnually
];
private readonly IDataProtector _dataProtector;

View File

@@ -11,21 +11,8 @@ public class Program
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
{
return false;
}
return e.Level >= globalSettings.MinLogLevel.ScimSettings.Default;
}));
})
.AddSerilogFileLogging()
.Build()
.Run();
}

View File

@@ -94,11 +94,8 @@ public class Startup
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings)
{
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

View File

@@ -30,6 +30,7 @@
},
"storage": {
"connectionString": "UseDevelopmentStorage=true"
}
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}
}

View File

@@ -30,9 +30,6 @@
"connectionString": "SECRET",
"applicationCacheTopicName": "SECRET"
},
"sentry": {
"dsn": "SECRET"
},
"notificationHub": {
"connectionString": "SECRET",
"hubName": "SECRET"

View File

@@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Security.Claims;
using System.Security.Claims;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
@@ -167,6 +164,8 @@ public class AccountController : Controller
{
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
if (!context.Parameters.AllKeys.Contains("domain_hint") ||
string.IsNullOrWhiteSpace(context.Parameters["domain_hint"]))
{
@@ -182,6 +181,7 @@ public class AccountController : Controller
var domainHint = context.Parameters["domain_hint"];
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
#nullable restore
if (organization == null)
{
@@ -263,30 +263,33 @@ public class AccountController : Controller
// See if the user has logged in with this SSO provider before and has already been provisioned.
// This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using.
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
var (possibleSsoLinkedUser, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
// We will look these up as required (lazy resolution) to avoid multiple DB hits.
Organization organization = null;
OrganizationUser orgUser = null;
Organization? organization = null;
OrganizationUser? orgUser = null;
// The user has not authenticated with this SSO provider before.
// They could have an existing Bitwarden account in the User table though.
if (user == null)
if (possibleSsoLinkedUser == null)
{
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
// If we're manually linking to SSO, the user's external identifier will be passed as query string parameter.
var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier")
? result.Properties.Items["user_identifier"]
: null;
var (provisionedUser, foundOrganization, foundOrCreatedOrgUser) =
await AutoProvisionUserAsync(
var (resolvedUser, foundOrganization, foundOrCreatedOrgUser) =
await CreateUserAndOrgUserConditionallyAsync(
provider,
providerUserId,
claims,
userIdentifier,
ssoConfigData);
#nullable restore
user = provisionedUser;
possibleSsoLinkedUser = resolvedUser;
if (preventOrgUserLoginIfStatusInvalid)
{
@@ -297,9 +300,10 @@ public class AccountController : Controller
if (preventOrgUserLoginIfStatusInvalid)
{
if (user == null) throw new Exception(_i18nService.T("UserShouldBeFound"));
User resolvedSsoLinkedUser = possibleSsoLinkedUser
?? throw new Exception(_i18nService.T("UserShouldBeFound"));
await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, user);
await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, resolvedSsoLinkedUser);
// This allows us to collect any additional claims or properties
// for the specific protocols used and store them in the local auth cookie.
@@ -314,19 +318,20 @@ public class AccountController : Controller
// Issue authentication cookie for user
await HttpContext.SignInAsync(
new IdentityServerUser(user.Id.ToString())
new IdentityServerUser(resolvedSsoLinkedUser.Id.ToString())
{
DisplayName = user.Email,
DisplayName = resolvedSsoLinkedUser.Email,
IdentityProvider = provider,
AdditionalClaims = additionalLocalClaims.ToArray()
}, localSignInProps);
}
else
{
// PM-24579: remove this else block with feature flag removal.
// Either the user already authenticated with the SSO provider, or we've just provisioned them.
// Either way, we have associated the SSO login with a Bitwarden user.
// We will now sign the Bitwarden user in.
if (user != null)
if (possibleSsoLinkedUser != null)
{
// This allows us to collect any additional claims or properties
// for the specific protocols used and store them in the local auth cookie.
@@ -341,9 +346,9 @@ public class AccountController : Controller
// Issue authentication cookie for user
await HttpContext.SignInAsync(
new IdentityServerUser(user.Id.ToString())
new IdentityServerUser(possibleSsoLinkedUser.Id.ToString())
{
DisplayName = user.Email,
DisplayName = possibleSsoLinkedUser.Email,
IdentityProvider = provider,
AdditionalClaims = additionalLocalClaims.ToArray()
}, localSignInProps);
@@ -353,8 +358,11 @@ public class AccountController : Controller
// Delete temporary cookie used during external authentication
await HttpContext.SignOutAsync(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
// Retrieve return URL
var returnUrl = result.Properties.Items["return_url"] ?? "~/";
#nullable restore
// Check if external login is in the context of an OIDC request
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
@@ -373,6 +381,8 @@ public class AccountController : Controller
return Redirect(returnUrl);
}
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
[HttpGet]
public async Task<IActionResult> LogoutAsync(string logoutId)
{
@@ -407,15 +417,22 @@ public class AccountController : Controller
return Redirect("~/");
}
}
#nullable restore
/// <summary>
/// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`.
/// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records.
/// </summary>
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims,
SsoConfigurationData config)>
FindUserFromExternalProviderAsync(AuthenticateResult result)
private async Task<(
User? possibleSsoUser,
string provider,
string providerUserId,
IEnumerable<Claim> claims,
SsoConfigurationData config
)> FindUserFromExternalProviderAsync(AuthenticateResult result)
{
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
var provider = result.Properties.Items["scheme"];
var orgId = new Guid(provider);
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
@@ -458,6 +475,7 @@ public class AccountController : Controller
externalUser.FindFirst("upn") ??
externalUser.FindFirst("eppn") ??
throw new Exception(_i18nService.T("UnknownUserId"));
#nullable restore
// Remove the user id claim so we don't include it as an extra claim if/when we provision the user
var claims = externalUser.Claims.ToList();
@@ -466,13 +484,15 @@ public class AccountController : Controller
// find external user
var providerUserId = userIdClaim.Value;
var user = await _userRepository.GetBySsoUserAsync(providerUserId, orgId);
var possibleSsoUser = await _userRepository.GetBySsoUserAsync(providerUserId, orgId);
return (user, provider, providerUserId, claims, ssoConfigData);
return (possibleSsoUser, provider, providerUserId, claims, ssoConfigData);
}
/// <summary>
/// Provision an SSO-linked Bitwarden user.
/// This function seeks to set up the org user record or create a new user record based on the conditions
/// below.
///
/// This handles three different scenarios:
/// 1. Creating an SsoUser link for an existing User and OrganizationUser
/// - User is a member of the organization, but hasn't authenticated with the org's SSO provider before.
@@ -488,8 +508,7 @@ public class AccountController : Controller
/// <param name="ssoConfigData">The SSO configuration for the organization.</param>
/// <returns>Guaranteed to return the user to sign in as well as the found organization and org user.</returns>
/// <exception cref="Exception">An exception if the user cannot be provisioned as requested.</exception>
private async Task<(User user, Organization foundOrganization, OrganizationUser foundOrgUser)>
AutoProvisionUserAsync(
private async Task<(User resolvedUser, Organization foundOrganization, OrganizationUser foundOrgUser)> CreateUserAndOrgUserConditionallyAsync(
string provider,
string providerUserId,
IEnumerable<Claim> claims,
@@ -497,10 +516,11 @@ public class AccountController : Controller
SsoConfigurationData ssoConfigData
)
{
// Try to get the email from the claims as we don't know if we have a user record yet.
var name = GetName(claims, ssoConfigData.GetAdditionalNameClaimTypes());
var email = TryGetEmailAddress(claims, ssoConfigData, providerUserId);
User existingUser = null;
User? possibleExistingUser;
if (string.IsNullOrWhiteSpace(userIdentifier))
{
if (string.IsNullOrWhiteSpace(email))
@@ -508,51 +528,74 @@ public class AccountController : Controller
throw new Exception(_i18nService.T("CannotFindEmailClaim"));
}
existingUser = await _userRepository.GetByEmailAsync(email);
possibleExistingUser = await _userRepository.GetByEmailAsync(email);
}
else
{
existingUser = await GetUserFromManualLinkingDataAsync(userIdentifier);
possibleExistingUser = await GetUserFromManualLinkingDataAsync(userIdentifier);
}
// Try to find the org (we error if we can't find an org)
var organization = await TryGetOrganizationByProviderAsync(provider);
// Find the org (we error if we can't find an org because no org is not valid)
var organization = await GetOrganizationByProviderAsync(provider);
// Try to find an org user (null org user possible and valid here)
var orgUser = await TryGetOrganizationUserByUserAndOrgOrEmail(existingUser, organization.Id, email);
var possibleOrgUser = await GetOrganizationUserByUserAndOrgIdOrEmailAsync(possibleExistingUser, organization.Id, email);
//----------------------------------------------------
// Scenario 1: We've found the user in the User table
//----------------------------------------------------
if (existingUser != null)
if (possibleExistingUser != null)
{
if (existingUser.UsesKeyConnector &&
(orgUser == null || orgUser.Status == OrganizationUserStatusType.Invited))
User guaranteedExistingUser = possibleExistingUser;
if (guaranteedExistingUser.UsesKeyConnector &&
(possibleOrgUser == null || possibleOrgUser.Status == OrganizationUserStatusType.Invited))
{
throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector"));
}
// If the user already exists in Bitwarden, we require that the user already be in the org,
// and that they are either Accepted or Confirmed.
if (orgUser == null)
OrganizationUser guaranteedOrgUser = possibleOrgUser ?? throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
/*
* ----------------------------------------------------
* Critical Code Check Here
*
* We want to ensure a user is not in the invited state
* explicitly. User's in the invited state should not
* be able to authenticate via SSO.
*
* See internal doc called "Added Context for SSO Login
* Flows" for further details.
* ----------------------------------------------------
*/
if (guaranteedOrgUser.Status == OrganizationUserStatusType.Invited)
{
// Org User is not created - no invite has been sent
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
// Org User is invited must accept via email first
throw new Exception(
_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName()));
}
EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName());
// If the user already exists in Bitwarden, we require that the user already be in the org,
// and that they are either Accepted or Confirmed.
EnforceAllowedOrgUserStatus(
guaranteedOrgUser.Status,
allowedStatuses: [
OrganizationUserStatusType.Accepted,
OrganizationUserStatusType.Confirmed
],
organization.DisplayName());
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not
// authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them).
// We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
// with authentication.
await CreateSsoUserRecordAsync(providerUserId, existingUser.Id, organization.Id, orgUser);
await CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser);
return (existingUser, organization, orgUser);
return (guaranteedExistingUser, organization, guaranteedOrgUser);
}
// Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one
if (orgUser == null && organization.Seats.HasValue)
if (possibleOrgUser == null && organization.Seats.HasValue)
{
var occupiedSeats =
await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
@@ -584,6 +627,11 @@ public class AccountController : Controller
}
// If the email domain is verified, we can mark the email as verified
if (string.IsNullOrWhiteSpace(email))
{
throw new Exception(_i18nService.T("CannotFindEmailClaim"));
}
var emailVerified = false;
var emailDomain = CoreHelpers.GetEmailDomain(email);
if (!string.IsNullOrWhiteSpace(emailDomain))
@@ -596,29 +644,45 @@ public class AccountController : Controller
//--------------------------------------------------
// Scenarios 2 and 3: We need to register a new user
//--------------------------------------------------
var user = new User
var newUser = new User
{
Name = name,
Email = email,
EmailVerified = emailVerified,
ApiKey = CoreHelpers.SecureRandomString(30)
};
await _registerUserCommand.RegisterUser(user);
/*
The feature flag is checked here so that we can send the new MJML welcome email templates.
The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability
to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need
to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email.
[PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users.
TODO: Remove Feature flag: PM-28221
*/
if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates))
{
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
}
else
{
await _registerUserCommand.RegisterUser(newUser);
}
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
{
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
newUser.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
{
[TwoFactorProviderType.Email] = new TwoFactorProvider
{
MetaData = new Dictionary<string, object> { ["Email"] = user.Email.ToLowerInvariant() },
MetaData = new Dictionary<string, object> { ["Email"] = newUser.Email.ToLowerInvariant() },
Enabled = true
}
});
await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email);
await _userService.UpdateTwoFactorProviderAsync(newUser, TwoFactorProviderType.Email);
}
//-----------------------------------------------------------------
@@ -626,16 +690,16 @@ public class AccountController : Controller
// This means that an invitation was not sent for this user and we
// need to establish their invited status now.
//-----------------------------------------------------------------
if (orgUser == null)
if (possibleOrgUser == null)
{
orgUser = new OrganizationUser
possibleOrgUser = new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user.Id,
UserId = newUser.Id,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Invited
};
await _organizationUserRepository.CreateAsync(orgUser);
await _organizationUserRepository.CreateAsync(possibleOrgUser);
}
//-----------------------------------------------------------------
@@ -645,14 +709,14 @@ public class AccountController : Controller
//-----------------------------------------------------------------
else
{
orgUser.UserId = user.Id;
await _organizationUserRepository.ReplaceAsync(orgUser);
possibleOrgUser.UserId = newUser.Id;
await _organizationUserRepository.ReplaceAsync(possibleOrgUser);
}
// Create the SsoUser record to link the user to the SSO provider.
await CreateSsoUserRecordAsync(providerUserId, user.Id, organization.Id, orgUser);
await CreateSsoUserRecordAsync(providerUserId, newUser.Id, organization.Id, possibleOrgUser);
return (user, organization, orgUser);
return (newUser, organization, possibleOrgUser);
}
/// <summary>
@@ -666,23 +730,31 @@ public class AccountController : Controller
/// <exception cref="Exception">Thrown if the organization cannot be resolved from provider;
/// the organization user cannot be found; or the organization user status is not allowed.</exception>
private async Task PreventOrgUserLoginIfStatusInvalidAsync(
Organization organization,
Organization? organization,
string provider,
OrganizationUser orgUser,
OrganizationUser? orgUser,
User user)
{
// Lazily get organization if not already known
organization ??= await TryGetOrganizationByProviderAsync(provider);
organization ??= await GetOrganizationByProviderAsync(provider);
// Lazily get the org user if not already known
orgUser ??= await TryGetOrganizationUserByUserAndOrgOrEmail(
orgUser ??= await GetOrganizationUserByUserAndOrgIdOrEmailAsync(
user,
organization.Id,
user.Email);
if (orgUser != null)
{
EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName());
// Invited is allowed at this point because we know the user is trying to accept an org invite.
EnforceAllowedOrgUserStatus(
orgUser.Status,
allowedStatuses: [
OrganizationUserStatusType.Invited,
OrganizationUserStatusType.Accepted,
OrganizationUserStatusType.Confirmed,
],
organization.DisplayName());
}
else
{
@@ -690,9 +762,9 @@ public class AccountController : Controller
}
}
private async Task<User> GetUserFromManualLinkingDataAsync(string userIdentifier)
private async Task<User?> GetUserFromManualLinkingDataAsync(string userIdentifier)
{
User user = null;
User? user = null;
var split = userIdentifier.Split(",");
if (split.Length < 2)
{
@@ -728,7 +800,7 @@ public class AccountController : Controller
/// </summary>
/// <param name="provider">Org id string from SSO scheme property</param>
/// <exception cref="Exception">Errors if the provider string is not a valid org id guid or if the org cannot be found by the id.</exception>
private async Task<Organization> TryGetOrganizationByProviderAsync(string provider)
private async Task<Organization> GetOrganizationByProviderAsync(string provider)
{
if (!Guid.TryParse(provider, out var organizationId))
{
@@ -755,12 +827,12 @@ public class AccountController : Controller
/// <param name="organizationId">Organization id from the provider data.</param>
/// <param name="email">Email to use as a fallback in case of an invited user not in the Org Users
/// table yet.</param>
private async Task<OrganizationUser> TryGetOrganizationUserByUserAndOrgOrEmail(
User user,
private async Task<OrganizationUser?> GetOrganizationUserByUserAndOrgIdOrEmailAsync(
User? user,
Guid organizationId,
string email)
string? email)
{
OrganizationUser orgUser = null;
OrganizationUser? orgUser = null;
// Try to find OrgUser via existing User Id.
// This covers any OrganizationUser state after they have accepted an invite.
@@ -772,44 +844,40 @@ public class AccountController : Controller
// If no Org User found by Existing User Id - search all the organization's users via email.
// This covers users who are Invited but haven't accepted their invite yet.
if (email != null)
{
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email);
}
return orgUser;
}
private void EnsureAcceptedOrConfirmedOrgUserStatus(
OrganizationUserStatusType status,
string organizationDisplayName)
private void EnforceAllowedOrgUserStatus(
OrganizationUserStatusType statusToCheckAgainst,
OrganizationUserStatusType[] allowedStatuses,
string organizationDisplayNameForLogging)
{
// The only permissible org user statuses allowed.
OrganizationUserStatusType[] allowedStatuses =
[OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed];
// if this status is one of the allowed ones, just return
if (allowedStatuses.Contains(status))
if (allowedStatuses.Contains(statusToCheckAgainst))
{
return;
}
// otherwise throw the appropriate exception
switch (status)
switch (statusToCheckAgainst)
{
case OrganizationUserStatusType.Invited:
// Org User is invited must accept via email first
throw new Exception(
_i18nService.T("AcceptInviteBeforeUsingSSO", organizationDisplayName));
case OrganizationUserStatusType.Revoked:
// Revoked users may not be (auto)provisioned
throw new Exception(
_i18nService.T("OrganizationUserAccessRevoked", organizationDisplayName));
_i18nService.T("OrganizationUserAccessRevoked", organizationDisplayNameForLogging));
default:
// anything else is “unknown”
throw new Exception(
_i18nService.T("OrganizationUserUnknownStatus", organizationDisplayName));
_i18nService.T("OrganizationUserUnknownStatus", organizationDisplayNameForLogging));
}
}
private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
private IActionResult InvalidJson(string errorMessageKey, Exception? ex = null)
{
Response.StatusCode = ex == null ? 400 : 500;
return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey))
@@ -820,7 +888,7 @@ public class AccountController : Controller
});
}
private string TryGetEmailAddressFromClaims(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
private string? TryGetEmailAddressFromClaims(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
{
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@"));
@@ -842,6 +910,8 @@ public class AccountController : Controller
return null;
}
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
private string GetName(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
{
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value));
@@ -865,6 +935,7 @@ public class AccountController : Controller
return null;
}
#nullable restore
private async Task CreateSsoUserRecordAsync(string providerUserId, Guid userId, Guid orgId,
OrganizationUser orgUser)
@@ -886,6 +957,8 @@ public class AccountController : Controller
await _ssoUserRepository.CreateAsync(ssoUser);
}
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
private void ProcessLoginCallback(AuthenticateResult externalResult,
List<Claim> localClaims, AuthenticationProperties localSignInProps)
{
@@ -936,12 +1009,13 @@ public class AccountController : Controller
return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme);
}
#nullable restore
/**
* Tries to get a user's email from the claims and SSO configuration data or the provider user id if
* the claims email extraction returns null.
*/
private string TryGetEmailAddress(
private string? TryGetEmailAddress(
IEnumerable<Claim> claims,
SsoConfigurationData config,
string providerUserId)

View File

@@ -1,5 +1,4 @@
using Bit.Core.Utilities;
using Serilog;
namespace Bit.Sso;
@@ -13,19 +12,8 @@ public class Program
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
{
return false;
}
return e.Level >= globalSettings.MinLogLevel.SsoSettings.Default;
}));
})
.AddSerilogFileLogging()
.Build()
.Run();
}

View File

@@ -100,8 +100,6 @@ public class Startup
IdentityModelEventSource.ShowPII = true;
}
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

View File

@@ -24,6 +24,7 @@
"storage": {
"connectionString": "UseDevelopmentStorage=true"
},
"developmentDirectory": "../../../dev"
"developmentDirectory": "../../../dev",
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}
}

View File

@@ -13,7 +13,7 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Core.Test.Billing.Mocks;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
@@ -207,7 +207,7 @@ public class RemoveOrganizationFromProviderCommandTests
organization.PlanType = PlanType.TeamsMonthly;
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
@@ -296,7 +296,7 @@ public class RemoveOrganizationFromProviderCommandTests
organization.PlanType = PlanType.TeamsMonthly;
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
@@ -416,7 +416,7 @@ public class RemoveOrganizationFromProviderCommandTests
organization.PlanType = PlanType.TeamsMonthly;
organization.Enabled = false; // Start with a disabled organization
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);

View File

@@ -20,6 +20,7 @@ using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
using Bit.Core.Test.Billing.Mocks;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
@@ -811,12 +812,12 @@ public class ProviderServiceTests
organization.Plan = "Enterprise (Monthly)";
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
.Returns(MockPlans.Get(organization.PlanType));
var expectedPlanType = PlanType.EnterpriseMonthly2020;
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(expectedPlanType)
.Returns(StaticStore.GetPlan(expectedPlanType));
.Returns(MockPlans.Get(expectedPlanType));
var expectedPlanId = "2020-enterprise-org-seat-monthly";

View File

@@ -18,6 +18,7 @@ using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Test.Billing.Mocks;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.DataProtection;
@@ -72,7 +73,7 @@ public class BusinessUnitConverterTests
{
organization.PlanType = PlanType.EnterpriseAnnually2020;
var enterpriseAnnually2020 = StaticStore.GetPlan(PlanType.EnterpriseAnnually2020);
var enterpriseAnnually2020 = MockPlans.Get(PlanType.EnterpriseAnnually2020);
var subscription = new Subscription
{
@@ -134,7 +135,7 @@ public class BusinessUnitConverterTests
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020)
.Returns(enterpriseAnnually2020);
var enterpriseAnnually = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
var enterpriseAnnually = MockPlans.Get(PlanType.EnterpriseAnnually);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually)
.Returns(enterpriseAnnually);
@@ -242,7 +243,7 @@ public class BusinessUnitConverterTests
argument.Status == ProviderStatusType.Pending &&
argument.Type == ProviderType.BusinessUnit)).Returns(provider);
var plan = StaticStore.GetPlan(organization.PlanType);
var plan = MockPlans.Get(organization.PlanType);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);

View File

@@ -22,7 +22,7 @@ using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Core.Test.Billing.Mocks;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Braintree;
@@ -140,7 +140,7 @@ public class ProviderBillingServiceTests
.Returns(existingPlan);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
.Returns(StaticStore.GetPlan(existingPlan.PlanType));
.Returns(MockPlans.Get(existingPlan.PlanType));
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider)
.Returns(new Subscription
@@ -155,7 +155,7 @@ public class ProviderBillingServiceTests
Id = "si_ent_annual",
Price = new Price
{
Id = StaticStore.GetPlan(PlanType.EnterpriseAnnually).PasswordManager
Id = MockPlans.Get(PlanType.EnterpriseAnnually).PasswordManager
.StripeProviderPortalSeatPlanId
},
Quantity = 10
@@ -168,7 +168,7 @@ public class ProviderBillingServiceTests
new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
.Returns(StaticStore.GetPlan(command.NewPlan));
.Returns(MockPlans.Get(command.NewPlan));
// Act
await sutProvider.Sut.ChangePlan(command);
@@ -185,7 +185,7 @@ public class ProviderBillingServiceTests
Arg.Is<SubscriptionUpdateOptions>(p =>
p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1));
var newPlanCfg = StaticStore.GetPlan(command.NewPlan);
var newPlanCfg = MockPlans.Get(command.NewPlan);
await stripeAdapter.Received(1)
.SubscriptionUpdateAsync(
Arg.Is(provider.GatewaySubscriptionId),
@@ -491,7 +491,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
@@ -514,7 +514,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 50 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[
@@ -573,7 +573,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
var providerPlan = providerPlans.First();
@@ -598,7 +598,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 95 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[
@@ -661,7 +661,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
var providerPlan = providerPlans.First();
@@ -686,7 +686,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 110 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[
@@ -749,7 +749,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
var providerPlan = providerPlans.First();
@@ -774,7 +774,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 110 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[
@@ -827,13 +827,13 @@ public class ProviderBillingServiceTests
}
]);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType));
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(MockPlans.Get(planType));
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[
new ProviderOrganizationOrganizationDetails
{
Plan = StaticStore.GetPlan(planType).Name,
Plan = MockPlans.Get(planType).Name,
Status = OrganizationStatusType.Managed,
Seats = 5
}
@@ -865,13 +865,13 @@ public class ProviderBillingServiceTests
}
]);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType));
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(MockPlans.Get(planType));
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[
new ProviderOrganizationOrganizationDetails
{
Plan = StaticStore.GetPlan(planType).Name,
Plan = MockPlans.Get(planType).Name,
Status = OrganizationStatusType.Managed,
Seats = 15
}
@@ -1238,7 +1238,7 @@ public class ProviderBillingServiceTests
.Returns(providerPlans);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.EnterpriseMonthly)
.Returns(StaticStore.GetPlan(PlanType.EnterpriseMonthly));
.Returns(MockPlans.Get(PlanType.EnterpriseMonthly));
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
@@ -1266,7 +1266,7 @@ public class ProviderBillingServiceTests
.Returns(providerPlans);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly)
.Returns(StaticStore.GetPlan(PlanType.TeamsMonthly));
.Returns(MockPlans.Get(PlanType.TeamsMonthly));
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
@@ -1317,7 +1317,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1373,7 +1373,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1449,7 +1449,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1525,7 +1525,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1626,7 +1626,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1704,7 +1704,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1772,8 +1772,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription
{
@@ -1806,7 +1806,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@@ -1852,8 +1852,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription
{
@@ -1886,7 +1886,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@@ -1932,8 +1932,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription
{
@@ -1966,7 +1966,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@@ -2006,8 +2006,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription
{
@@ -2040,7 +2040,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@@ -2086,8 +2086,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription
{
@@ -2120,7 +2120,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType));
.Returns(MockPlans.Get(plan.PlanType));
}
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);

View File

@@ -6,7 +6,7 @@ using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Core.Test.Billing.Mocks;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
@@ -69,7 +69,7 @@ public class MaxProjectsQueryTests
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
.Returns(MockPlans.Get(organization.PlanType));
var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1);
@@ -114,7 +114,7 @@ public class MaxProjectsQueryTests
.Returns(projects);
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
.Returns(MockPlans.Get(organization.PlanType));
var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd);

View File

@@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
@@ -18,6 +19,7 @@ using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
@@ -74,17 +76,6 @@ public class AccountControllerTest
return resolvedAuthService;
}
private static void InvokeEnsureOrgUserStatusAllowed(
AccountController controller,
OrganizationUserStatusType status)
{
var method = typeof(AccountController).GetMethod(
"EnsureAcceptedOrConfirmedOrgUserStatus",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
method.Invoke(controller, [status, "Org"]);
}
private static AuthenticateResult BuildSuccessfulExternalAuth(Guid orgId, string providerUserId, string email)
{
var claims = new[]
@@ -241,82 +232,6 @@ public class AccountControllerTest
return counts;
}
[Theory, BitAutoData]
public void EnsureOrgUserStatusAllowed_AllowsAcceptedAndConfirmed(
SutProvider<AccountController> sutProvider)
{
// Arrange
sutProvider.GetDependency<II18nService>()
.T(Arg.Any<string>(), Arg.Any<object?[]>())
.Returns(ci => (string)ci[0]!);
// Act
var ex1 = Record.Exception(() =>
InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Accepted));
var ex2 = Record.Exception(() =>
InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Confirmed));
// Assert
Assert.Null(ex1);
Assert.Null(ex2);
}
[Theory, BitAutoData]
public void EnsureOrgUserStatusAllowed_Invited_ThrowsAcceptInvite(
SutProvider<AccountController> sutProvider)
{
// Arrange
sutProvider.GetDependency<II18nService>()
.T(Arg.Any<string>(), Arg.Any<object?[]>())
.Returns(ci => (string)ci[0]!);
// Act
var ex = Assert.Throws<TargetInvocationException>(() =>
InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Invited));
// Assert
Assert.IsType<Exception>(ex.InnerException);
Assert.Equal("AcceptInviteBeforeUsingSSO", ex.InnerException!.Message);
}
[Theory, BitAutoData]
public void EnsureOrgUserStatusAllowed_Revoked_ThrowsAccessRevoked(
SutProvider<AccountController> sutProvider)
{
// Arrange
sutProvider.GetDependency<II18nService>()
.T(Arg.Any<string>(), Arg.Any<object?[]>())
.Returns(ci => (string)ci[0]!);
// Act
var ex = Assert.Throws<TargetInvocationException>(() =>
InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Revoked));
// Assert
Assert.IsType<Exception>(ex.InnerException);
Assert.Equal("OrganizationUserAccessRevoked", ex.InnerException!.Message);
}
[Theory, BitAutoData]
public void EnsureOrgUserStatusAllowed_UnknownStatus_ThrowsUnknown(
SutProvider<AccountController> sutProvider)
{
// Arrange
sutProvider.GetDependency<II18nService>()
.T(Arg.Any<string>(), Arg.Any<object?[]>())
.Returns(ci => (string)ci[0]!);
var unknown = (OrganizationUserStatusType)999;
// Act
var ex = Assert.Throws<TargetInvocationException>(() =>
InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, unknown));
// Assert
Assert.IsType<Exception>(ex.InnerException);
Assert.Equal("OrganizationUserUnknownStatus", ex.InnerException!.Message);
}
[Theory, BitAutoData]
public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_NoOrgUser_ThrowsCouldNotFindOrganizationUser(
SutProvider<AccountController> sutProvider)
@@ -357,7 +272,7 @@ public class AccountControllerTest
}
[Theory, BitAutoData]
public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserInvited_ThrowsAcceptInvite(
public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserInvited_AllowsLogin(
SutProvider<AccountController> sutProvider)
{
// Arrange
@@ -374,7 +289,7 @@ public class AccountControllerTest
};
var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!);
SetupHttpContextWithAuth(sutProvider, authResult);
var authService = SetupHttpContextWithAuth(sutProvider, authResult);
sutProvider.GetDependency<II18nService>()
.T(Arg.Any<string>(), Arg.Any<object?[]>())
@@ -392,9 +307,23 @@ public class AccountControllerTest
sutProvider.GetDependency<IIdentityServerInteractionService>()
.GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null);
// Act + Assert
var ex = await Assert.ThrowsAsync<Exception>(() => sutProvider.Sut.ExternalCallback());
Assert.Equal("AcceptInviteBeforeUsingSSO", ex.Message);
// Act
var result = await sutProvider.Sut.ExternalCallback();
// Assert
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("~/", redirect.Url);
await authService.Received().SignInAsync(
Arg.Any<HttpContext>(),
Arg.Any<string?>(),
Arg.Any<ClaimsPrincipal>(),
Arg.Any<AuthenticationProperties>());
await authService.Received().SignOutAsync(
Arg.Any<HttpContext>(),
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme,
Arg.Any<AuthenticationProperties>());
}
[Theory, BitAutoData]
@@ -930,13 +859,13 @@ public class AccountControllerTest
}
[Theory, BitAutoData]
public async Task AutoProvisionUserAsync_WithExistingAcceptedUser_CreatesSsoLinkAndReturnsUser(
public async Task CreateUserAndOrgUserConditionallyAsync_WithExistingAcceptedUser_CreatesSsoLinkAndReturnsUser(
SutProvider<AccountController> sutProvider)
{
// Arrange
var orgId = Guid.NewGuid();
var providerUserId = "ext-456";
var email = "jit@example.com";
var providerUserId = "provider-user-id";
var email = "user@example.com";
var existingUser = new User { Id = Guid.NewGuid(), Email = email };
var organization = new Organization { Id = orgId, Name = "Org" };
var orgUser = new OrganizationUser
@@ -965,12 +894,12 @@ public class AccountControllerTest
var config = new SsoConfigurationData();
var method = typeof(AccountController).GetMethod(
"AutoProvisionUserAsync",
"CreateUserAndOrgUserConditionallyAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// Act
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(sutProvider.Sut, new object[]
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method.Invoke(sutProvider.Sut, new object[]
{
orgId.ToString(),
providerUserId,
@@ -992,6 +921,61 @@ public class AccountControllerTest
EventType.OrganizationUser_FirstSsoLogin);
}
[Theory, BitAutoData]
public async Task CreateUserAndOrgUserConditionallyAsync_WithExistingInvitedUser_ThrowsAcceptInviteBeforeUsingSSO(
SutProvider<AccountController> sutProvider)
{
// Arrange
var orgId = Guid.NewGuid();
var providerUserId = "provider-user-id";
var email = "user@example.com";
var existingUser = new User { Id = Guid.NewGuid(), Email = email, UsesKeyConnector = false };
var organization = new Organization { Id = orgId, Name = "Org" };
var orgUser = new OrganizationUser
{
OrganizationId = orgId,
UserId = existingUser.Id,
Status = OrganizationUserStatusType.Invited,
Type = OrganizationUserType.User
};
// i18n returns the key so we can assert on message contents
sutProvider.GetDependency<II18nService>()
.T(Arg.Any<string>(), Arg.Any<object?[]>())
.Returns(ci => (string)ci[0]!);
// Arrange repository expectations for the flow
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns(existingUser);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByUserAsync(existingUser.Id)
.Returns(new List<OrganizationUser> { orgUser });
var claims = new[]
{
new Claim(JwtClaimTypes.Email, email),
new Claim(JwtClaimTypes.Name, "Invited User")
} as IEnumerable<Claim>;
var config = new SsoConfigurationData();
var method = typeof(AccountController).GetMethod(
"CreateUserAndOrgUserConditionallyAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// Act + Assert
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method.Invoke(sutProvider.Sut, new object[]
{
orgId.ToString(),
providerUserId,
claims,
null!,
config
})!;
var ex = await Assert.ThrowsAsync<Exception>(async () => await task);
Assert.Equal("AcceptInviteBeforeUsingSSO", ex.Message);
}
/// <summary>
/// PM-24579: Temporary comparison test to ensure the feature flag ON does not
/// regress lookup counts compared to OFF. When removing the flag, delete this
@@ -1026,4 +1010,131 @@ public class AccountControllerTest
_output.WriteLine($"Scenario={scenario} | OFF: SSO={offCounts.UserGetBySso}, Email={offCounts.UserGetByEmail}, Org={offCounts.OrgGetById}, OrgUserByOrg={offCounts.OrgUserGetByOrg}, OrgUserByEmail={offCounts.OrgUserGetByEmail}");
}
}
[Theory, BitAutoData]
public async Task AutoProvisionUserAsync_WithFeatureFlagEnabled_CallsRegisterSSOAutoProvisionedUser(
SutProvider<AccountController> sutProvider)
{
// Arrange
var orgId = Guid.NewGuid();
var providerUserId = "ext-new-user";
var email = "newuser@example.com";
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
// No existing user (JIT provisioning scenario)
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
.Returns((OrganizationUser?)null);
// Feature flag enabled
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
// Mock the RegisterSSOAutoProvisionedUserAsync to return success
sutProvider.GetDependency<IRegisterUserCommand>()
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>())
.Returns(IdentityResult.Success);
var claims = new[]
{
new Claim(JwtClaimTypes.Email, email),
new Claim(JwtClaimTypes.Name, "New User")
} as IEnumerable<Claim>;
var config = new SsoConfigurationData();
var method = typeof(AccountController).GetMethod(
"CreateUserAndOrgUserConditionallyAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// Act
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
sutProvider.Sut,
new object[]
{
orgId.ToString(),
providerUserId,
claims,
null!,
config
})!;
var result = await task;
// Assert
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
.RegisterSSOAutoProvisionedUserAsync(
Arg.Is<User>(u => u.Email == email && u.Name == "New User"),
Arg.Is<Organization>(o => o.Id == orgId && o.Name == "Test Org"));
Assert.NotNull(result.user);
Assert.Equal(email, result.user.Email);
Assert.Equal(organization.Id, result.organization.Id);
}
[Theory, BitAutoData]
public async Task AutoProvisionUserAsync_WithFeatureFlagDisabled_CallsRegisterUserInstead(
SutProvider<AccountController> sutProvider)
{
// Arrange
var orgId = Guid.NewGuid();
var providerUserId = "ext-legacy-user";
var email = "legacyuser@example.com";
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
// No existing user (JIT provisioning scenario)
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
.Returns((OrganizationUser?)null);
// Feature flag disabled
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(false);
// Mock the RegisterUser to return success
sutProvider.GetDependency<IRegisterUserCommand>()
.RegisterUser(Arg.Any<User>())
.Returns(IdentityResult.Success);
var claims = new[]
{
new Claim(JwtClaimTypes.Email, email),
new Claim(JwtClaimTypes.Name, "Legacy User")
} as IEnumerable<Claim>;
var config = new SsoConfigurationData();
var method = typeof(AccountController).GetMethod(
"CreateUserAndOrgUserConditionallyAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// Act
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
sutProvider.Sut,
new object[]
{
orgId.ToString(),
providerUserId,
claims,
null!,
config
})!;
var result = await task;
// Assert
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
.RegisterUser(Arg.Is<User>(u => u.Email == email && u.Name == "Legacy User"));
// Verify the new method was NOT called
await sutProvider.GetDependency<IRegisterUserCommand>().DidNotReceive()
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>());
Assert.NotNull(result.user);
Assert.Equal(email, result.user.Email);
}
}

View File

@@ -0,0 +1,36 @@
{
"globalSettings": {
"baseServiceUri": {
"vault": "https://localhost:8080",
"api": "http://localhost:4000",
"identity": "http://localhost:33656",
"admin": "http://localhost:62911",
"notifications": "http://localhost:61840",
"sso": "http://localhost:51822",
"internalNotifications": "http://localhost:61840",
"internalAdmin": "http://localhost:62911",
"internalIdentity": "http://localhost:33656",
"internalApi": "http://localhost:4000",
"internalVault": "https://localhost:8080",
"internalSso": "http://localhost:51822",
"internalScim": "http://localhost:44559"
},
"mail": {
"smtp": {
"host": "localhost",
"port": 10250
}
},
"attachment": {
"connectionString": "UseDevelopmentStorage=true",
"baseUrl": "http://localhost:4000/attachments/"
},
"events": {
"connectionString": "UseDevelopmentStorage=true"
},
"storage": {
"connectionString": "UseDevelopmentStorage=true"
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}
}

View File

@@ -57,7 +57,6 @@ services:
mysql:
image: mysql:8.0
container_name: bw-mysql
ports:
- "3306:3306"
command:
@@ -88,7 +87,6 @@ services:
idp:
image: kenchan0130/simplesamlphp:1.19.8
container_name: idp
ports:
- "8090:8080"
environment:
@@ -102,7 +100,6 @@ services:
rabbitmq:
image: rabbitmq:4.1.3-management
container_name: rabbitmq
ports:
- "5672:5672"
- "15672:15672"
@@ -116,7 +113,6 @@ services:
reverse-proxy:
image: nginx:alpine
container_name: reverse-proxy
volumes:
- "./reverse-proxy.conf:/etc/nginx/conf.d/default.conf"
ports:
@@ -126,7 +122,6 @@ services:
- proxy
service-bus:
container_name: service-bus
image: mcr.microsoft.com/azure-messaging/servicebus-emulator:latest
pull_policy: always
volumes:
@@ -142,7 +137,6 @@ services:
redis:
image: redis:alpine
container_name: bw-redis
ports:
- "6379:6379"
volumes:

View File

@@ -18,11 +18,11 @@ if ($LASTEXITCODE -ne 0) {
# Api internal & public
Set-Location "../../src/Api"
dotnet build
dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal"
dotnet swagger tofile --output "../../api.json" "./bin/Debug/net8.0/Api.dll" "internal"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public"
dotnet swagger tofile --output "../../api.public.json" "./bin/Debug/net8.0/Api.dll" "public"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}

132
dev/verify_migrations.ps1 Normal file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Validates that new database migration files follow naming conventions and chronological order.
.DESCRIPTION
This script validates migration files in util/Migrator/DbScripts/ to ensure:
1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql
2. New migrations are chronologically ordered (filename sorts after existing migrations)
3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5)
4. A 2-digit sequence number is included (e.g., _00, _01)
.PARAMETER BaseRef
The base git reference to compare against (e.g., 'main', 'HEAD~1')
.PARAMETER CurrentRef
The current git reference (defaults to 'HEAD')
.EXAMPLE
# For pull requests - compare against main branch
.\verify_migrations.ps1 -BaseRef main
.EXAMPLE
# For pushes - compare against previous commit
.\verify_migrations.ps1 -BaseRef HEAD~1
#>
param(
[Parameter(Mandatory = $true)]
[string]$BaseRef,
[Parameter(Mandatory = $false)]
[string]$CurrentRef = "HEAD"
)
# Use invariant culture for consistent string comparison
[System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::InvariantCulture
$migrationPath = "util/Migrator/DbScripts"
# Get list of migrations from base reference
try {
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/*.sql" 2>$null | Sort-Object
if ($LASTEXITCODE -ne 0) {
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
$baseMigrations = @()
}
}
catch {
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
$baseMigrations = @()
}
# Get list of migrations from current reference
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/*.sql" | Sort-Object
# Find added migrations
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
if ($addedMigrations.Count -eq 0) {
Write-Host "No new migration files added."
exit 0
}
Write-Host "New migration files detected:"
$addedMigrations | ForEach-Object { Write-Host " $_" }
Write-Host ""
# Get the last migration from base reference
if ($baseMigrations.Count -eq 0) {
Write-Host "No previous migrations found (initial commit?). Skipping validation."
exit 0
}
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
Write-Host "Last migration in base reference: $lastBaseMigration"
Write-Host ""
# Required format regex: YYYY-MM-DD_NN_Description.sql
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
$validationFailed = $false
foreach ($migration in $addedMigrations) {
$migrationName = Split-Path -Leaf $migration
# Validate NEW migration filename format
if ($migrationName -notmatch $formatRegex) {
Write-Host "ERROR: Migration '$migrationName' does not match required format"
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
Write-Host " - YYYY: 4-digit year"
Write-Host " - MM: 2-digit month with leading zero (01-12)"
Write-Host " - DD: 2-digit day with leading zero (01-31)"
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
$validationFailed = $true
continue
}
# Compare migration name with last base migration (using ordinal string comparison)
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
$validationFailed = $true
}
else {
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
}
}
Write-Host ""
if ($validationFailed) {
Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order"
Write-Host ""
Write-Host "All new migration files must:"
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
Write-Host " 4. Have a filename that sorts after the last migration in base"
Write-Host ""
Write-Host "To fix this issue:"
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 3. Ensure the date is after $lastBaseMigration"
Write-Host ""
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
exit 1
}
Write-Host "SUCCESS: All new migrations are correctly named and in chronological order"
exit 0

View File

@@ -474,6 +474,7 @@ public class OrganizationsController : Controller
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
organization.UseDisableSmAdsForUsers = model.UseDisableSmAdsForUsers;
organization.UsePhishingBlocker = model.UsePhishingBlocker;
//secrets
organization.SmSeats = model.SmSeats;

View File

@@ -108,6 +108,7 @@ public class OrganizationEditModel : OrganizationViewModel
UseOrganizationDomains = org.UseOrganizationDomains;
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
UseDisableSmAdsForUsers = org.UseDisableSmAdsForUsers;
UsePhishingBlocker = org.UsePhishingBlocker;
_plans = plans;
}
@@ -161,6 +162,8 @@ public class OrganizationEditModel : OrganizationViewModel
public new bool UseSecretsManager { get; set; }
[Display(Name = "Risk Insights")]
public new bool UseRiskInsights { get; set; }
[Display(Name = "Phishing Blocker")]
public new bool UsePhishingBlocker { get; set; }
[Display(Name = "Admin Sponsored Families")]
public bool UseAdminSponsoredFamilies { get; set; }
[Display(Name = "Self Host")]
@@ -331,6 +334,7 @@ public class OrganizationEditModel : OrganizationViewModel
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
existingOrganization.UseDisableSmAdsForUsers = UseDisableSmAdsForUsers;
existingOrganization.UsePhishingBlocker = UsePhishingBlocker;
return existingOrganization;
}
}

View File

@@ -75,6 +75,7 @@ public class OrganizationViewModel
public int OccupiedSmSeatsCount { get; set; }
public bool UseSecretsManager => Organization.UseSecretsManager;
public bool UseRiskInsights => Organization.UseRiskInsights;
public bool UsePhishingBlocker => Organization.UsePhishingBlocker;
public IEnumerable<OrganizationUserUserDetails> OwnersDetails { get; set; }
public IEnumerable<OrganizationUserUserDetails> AdminsDetails { get; set; }
}

View File

@@ -156,6 +156,10 @@
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UsePhishingBlocker" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UsePhishingBlocker"></label>
</div>
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
<div class="form-check">

View File

@@ -19,7 +19,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
public virtual async Task StartAsync(CancellationToken cancellationToken)
{
// Wait 20 seconds to allow database to come online
await Task.Delay(20000);
await Task.Delay(20000, cancellationToken);
var maxMigrationAttempts = 10;
for (var i = 1; i <= maxMigrationAttempts; i++)
@@ -41,7 +41,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
{
_logger.LogError(e,
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
await Task.Delay(20000);
await Task.Delay(20000, cancellationToken);
}
}
}

View File

@@ -16,19 +16,8 @@ public class Program
o.Limits.MaxRequestLineSize = 20_000;
});
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
{
return false;
}
return e.Level >= globalSettings.MinLogLevel.AdminSettings.Default;
}));
})
.AddSerilogFileLogging()
.Build()
.Run();
}

View File

@@ -132,11 +132,8 @@ public class Startup
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings)
{
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

View File

@@ -27,6 +27,7 @@
},
"storage": {
"connectionString": "UseDevelopmentStorage=true"
}
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}
}

View File

@@ -0,0 +1,26 @@
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Models.Api;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
public abstract class BaseAdminConsoleController : Controller
{
protected static IResult Handle(CommandResult commandResult) =>
commandResult.Match<IResult>(
error => error switch
{
BadRequestError badRequest => TypedResults.BadRequest(new ErrorResponseModel(badRequest.Message)),
NotFoundError notFound => TypedResults.NotFound(new ErrorResponseModel(notFound.Message)),
InternalError internalError => TypedResults.Json(
new ErrorResponseModel(internalError.Message),
statusCode: StatusCodes.Status500InternalServerError),
_ => TypedResults.Json(
new ErrorResponseModel(error.Message),
statusCode: StatusCodes.Status500InternalServerError
)
},
_ => TypedResults.NoContent()
);
}

View File

@@ -1,16 +1,13 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
[Authorize("Application")]
public class OrganizationIntegrationConfigurationController(

View File

@@ -1,18 +1,13 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
#nullable enable
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations")]
[Authorize("Application")]
public class OrganizationIntegrationController(

View File

@@ -11,8 +11,10 @@ using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
@@ -20,6 +22,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Pricing;
@@ -43,7 +46,7 @@ namespace Bit.Api.AdminConsole.Controllers;
[Route("organizations/{orgId}/users")]
[Authorize("Application")]
public class OrganizationUsersController : Controller
public class OrganizationUsersController : BaseAdminConsoleController
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
@@ -68,6 +71,7 @@ public class OrganizationUsersController : Controller
private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand;
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
@@ -101,7 +105,8 @@ public class OrganizationUsersController : Controller
IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand)
IAdminRecoverAccountCommand adminRecoverAccountCommand,
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -126,6 +131,7 @@ public class OrganizationUsersController : Controller
_featureService = featureService;
_pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand;
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_initPendingOrganizationCommand = initPendingOrganizationCommand;
@@ -477,43 +483,10 @@ public class OrganizationUsersController : Controller
}
}
#nullable enable
[HttpPut("{id}/reset-password")]
[Authorize<ManageAccountRecoveryRequirement>]
public async Task<IResult> PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand))
{
// TODO: remove legacy implementation after feature flag is enabled.
return await PutResetPasswordNew(orgId, id, model);
}
// Get the users role, since provider users aren't a member of the organization we use the owner check
var orgUserType = await _currentContext.OrganizationOwner(orgId)
? OrganizationUserType.Owner
: _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type;
if (orgUserType == null)
{
return TypedResults.NotFound();
}
var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded)
{
return TypedResults.Ok();
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
await Task.Delay(2000);
return TypedResults.BadRequest(ModelState);
}
#nullable enable
// TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed.
private async Task<IResult> PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId)
@@ -738,6 +711,31 @@ public class OrganizationUsersController : Controller
await BulkEnableSecretsManagerAsync(orgId, model);
}
[HttpPost("{id}/auto-confirm")]
[Authorize<ManageUsersRequirement>]
[RequireFeature(FeatureFlagKeys.AutomaticConfirmUsers)]
public async Task<IResult> AutomaticallyConfirmOrganizationUserAsync([FromRoute] Guid orgId,
[FromRoute] Guid id,
[FromBody] OrganizationUserConfirmRequestModel model)
{
var userId = _userService.GetProperUserId(User);
if (userId is null || userId.Value == Guid.Empty)
{
return TypedResults.Unauthorized();
}
return Handle(await _automaticallyConfirmOrganizationUserCommand.AutomaticallyConfirmOrganizationUserAsync(
new AutomaticallyConfirmOrganizationUserRequest
{
OrganizationId = orgId,
OrganizationUserId = id,
Key = model.Key,
DefaultUserCollectionName = model.DefaultUserCollectionName,
PerformedBy = new StandardUser(userId.Value, await _currentContext.OrganizationOwner(orgId)),
}));
}
private async Task RestoreOrRevokeUserAsync(
Guid orgId,
Guid id,

View File

@@ -12,7 +12,6 @@ using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
@@ -70,6 +69,7 @@ public class OrganizationsController : Controller
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient;
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
private readonly IOrganizationUpdateCommand _organizationUpdateCommand;
public OrganizationsController(
IOrganizationRepository organizationRepository,
@@ -94,7 +94,8 @@ public class OrganizationsController : Controller
IOrganizationDeleteCommand organizationDeleteCommand,
IPolicyRequirementQuery policyRequirementQuery,
IPricingClient pricingClient,
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand)
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand,
IOrganizationUpdateCommand organizationUpdateCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -119,6 +120,7 @@ public class OrganizationsController : Controller
_policyRequirementQuery = policyRequirementQuery;
_pricingClient = pricingClient;
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
_organizationUpdateCommand = organizationUpdateCommand;
}
[HttpGet("{id}")]
@@ -224,36 +226,31 @@ public class OrganizationsController : Controller
return new OrganizationResponseModel(result.Organization, plan);
}
[HttpPut("{id}")]
public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model)
[HttpPut("{organizationId:guid}")]
public async Task<IResult> Put(Guid organizationId, [FromBody] OrganizationUpdateRequestModel model)
{
var orgIdGuid = new Guid(id);
// If billing email is being changed, require subscription editing permissions.
// Otherwise, organization owner permissions are sufficient.
var requiresBillingPermission = model.BillingEmail is not null;
var authorized = requiresBillingPermission
? await _currentContext.EditSubscription(organizationId)
: await _currentContext.OrganizationOwner(organizationId);
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
if (organization == null)
if (!authorized)
{
throw new NotFoundException();
return TypedResults.Unauthorized();
}
var updateBilling = ShouldUpdateBilling(model, organization);
var commandRequest = model.ToCommandRequest(organizationId);
var updatedOrganization = await _organizationUpdateCommand.UpdateAsync(commandRequest);
var hasRequiredPermissions = updateBilling
? await _currentContext.EditSubscription(orgIdGuid)
: await _currentContext.OrganizationOwner(orgIdGuid);
if (!hasRequiredPermissions)
{
throw new NotFoundException();
}
await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling);
var plan = await _pricingClient.GetPlan(organization.PlanType);
return new OrganizationResponseModel(organization, plan);
var plan = await _pricingClient.GetPlan(updatedOrganization.PlanType);
return TypedResults.Ok(new OrganizationResponseModel(updatedOrganization, plan));
}
[HttpPost("{id}")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
public async Task<OrganizationResponseModel> PostPut(string id, [FromBody] OrganizationUpdateRequestModel model)
public async Task<IResult> PostPut(Guid id, [FromBody] OrganizationUpdateRequestModel model)
{
return await Put(id, model);
}
@@ -588,11 +585,4 @@ public class OrganizationsController : Controller
return organization.PlanType;
}
private bool ShouldUpdateBilling(OrganizationUpdateRequestModel model, Organization organization)
{
var organizationNameChanged = model.Name != organization.Name;
var billingEmailChanged = model.BillingEmail != organization.BillingEmail;
return !_globalSettings.SelfHosted && (organizationNameChanged || billingEmailChanged);
}
}

View File

@@ -209,23 +209,17 @@ public class PoliciesController : Controller
throw new NotFoundException();
}
if (type != model.Type)
{
throw new BadRequestException("Mismatched policy type");
}
var policyUpdate = await model.ToPolicyUpdateAsync(orgId, _currentContext);
var policyUpdate = await model.ToPolicyUpdateAsync(orgId, type, _currentContext);
var policy = await _savePolicyCommand.SaveAsync(policyUpdate);
return new PolicyResponseModel(policy);
}
[HttpPut("{type}/vnext")]
[RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)]
[Authorize<ManagePoliciesRequirement>]
public async Task<PolicyResponseModel> PutVNext(Guid orgId, [FromBody] SavePolicyRequest model)
public async Task<PolicyResponseModel> PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model)
{
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, _currentContext);
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext);
var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ?
await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) :
@@ -233,5 +227,4 @@ public class PoliciesController : Controller
return new PolicyResponseModel(policy);
}
}

View File

@@ -1,6 +1,5 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Context;
@@ -8,13 +7,11 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations")]
[Authorize("Application")]
public class SlackIntegrationController(

View File

@@ -1,6 +1,5 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Context;
@@ -8,7 +7,6 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Bot.Builder;
@@ -16,7 +14,6 @@ using Microsoft.Bot.Builder.Integration.AspNet.Core;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations")]
[Authorize("Application")]
public class TeamsIntegrationController(

View File

@@ -1,41 +1,28 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Data;
using Bit.Core.Settings;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationUpdateRequestModel
{
[Required]
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string BusinessName { get; set; }
[EmailAddress]
[Required]
[StringLength(256)]
public string BillingEmail { get; set; }
public Permissions Permissions { get; set; }
public OrganizationKeysRequestModel Keys { get; set; }
public string? Name { get; set; }
public virtual Organization ToOrganization(Organization existingOrganization, GlobalSettings globalSettings)
[EmailAddress]
[StringLength(256)]
public string? BillingEmail { get; set; }
public OrganizationKeysRequestModel? Keys { get; set; }
public OrganizationUpdateRequest ToCommandRequest(Guid organizationId) => new()
{
if (!globalSettings.SelfHosted)
{
// These items come from the license file
existingOrganization.Name = Name;
existingOrganization.BusinessName = BusinessName;
existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim();
}
Keys?.ToOrganization(existingOrganization);
return existingOrganization;
}
OrganizationId = organizationId,
Name = Name,
BillingEmail = BillingEmail,
PublicKey = Keys?.PublicKey,
EncryptedPrivateKey = Keys?.EncryptedPrivateKey
};
}

View File

@@ -9,20 +9,18 @@ namespace Bit.Api.AdminConsole.Models.Request;
public class PolicyRequestModel
{
[Required]
public PolicyType? Type { get; set; }
[Required]
public bool? Enabled { get; set; }
public Dictionary<string, object>? Data { get; set; }
public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext)
public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext)
{
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, Type!.Value);
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type);
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
return new()
{
Type = Type!.Value,
Type = type,
OrganizationId = organizationId,
Data = serializedData,
Enabled = Enabled.GetValueOrDefault(),

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Utilities;
@@ -13,10 +14,10 @@ public class SavePolicyRequest
public Dictionary<string, object>? Metadata { get; set; }
public async Task<SavePolicyModel> ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext)
public async Task<SavePolicyModel> ToSavePolicyModelAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext)
{
var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, currentContext);
var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, Policy.Type!.Value);
var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, type, currentContext);
var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, type);
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
return new SavePolicyModel(policyUpdate, performedBy, metadata);

View File

@@ -47,6 +47,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
UseSecretsManager = organizationDetails.UseSecretsManager;
UsePhishingBlocker = organizationDetails.UsePhishingBlocker;
UsePasswordManager = organizationDetails.UsePasswordManager;
SelfHost = organizationDetails.SelfHost;
Seats = organizationDetails.Seats;
@@ -99,6 +100,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UsePhishingBlocker { get; set; }
public bool SelfHost { get; set; }
public int? Seats { get; set; }
public short? MaxCollections { get; set; }

View File

@@ -2,8 +2,6 @@
using Bit.Core.Enums;
using Bit.Core.Models.Api;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
@@ -11,8 +9,6 @@ public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration")
: base(obj)
{
ArgumentNullException.ThrowIfNull(organizationIntegrationConfiguration);
Id = organizationIntegrationConfiguration.Id;
Configuration = organizationIntegrationConfiguration.Configuration;
CreationDate = organizationIntegrationConfiguration.CreationDate;

View File

@@ -72,6 +72,7 @@ public class OrganizationResponseModel : ResponseModel
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers;
UsePhishingBlocker = organization.UsePhishingBlocker;
}
public Guid Id { get; set; }
@@ -122,6 +123,7 @@ public class OrganizationResponseModel : ResponseModel
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UseDisableSmAdsForUsers { get; set; }
public bool UsePhishingBlocker { get; set; }
}
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel

View File

@@ -30,6 +30,7 @@ public class PolicyResponseModel : ResponseModel
{
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(policy.Data);
}
RevisionDate = policy.RevisionDate;
}
public Guid Id { get; set; }
@@ -37,4 +38,5 @@ public class PolicyResponseModel : ResponseModel
public PolicyType Type { get; set; }
public Dictionary<string, object> Data { get; set; }
public bool Enabled { get; set; }
public DateTime RevisionDate { get; set; }
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Utilities;
@@ -27,7 +28,7 @@ public class ProfileOrganizationResponseModel : BaseProfileOrganizationResponseM
FamilySponsorshipToDelete = organizationDetails.FamilySponsorshipToDelete;
FamilySponsorshipValidUntil = organizationDetails.FamilySponsorshipValidUntil;
FamilySponsorshipAvailable = (organizationDetails.FamilySponsorshipFriendlyName == null || IsAdminInitiated) &&
StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise)
SponsoredPlans.Get(PlanSponsorshipType.FamiliesForEnterprise)
.UsersCanSponsor(organizationDetails);
AccessSecretsManager = organizationDetails.AccessSecretsManager;
}

View File

@@ -1,6 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable

using System.Net;
using Bit.Api.Models.Public.Request;
using Bit.Api.Models.Public.Response;
@@ -8,6 +6,7 @@ using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core.Context;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization;
@@ -22,6 +21,9 @@ public class EventsController : Controller
private readonly IEventRepository _eventRepository;
private readonly ICipherRepository _cipherRepository;
private readonly ICurrentContext _currentContext;
private readonly ISecretRepository _secretRepository;
private readonly IProjectRepository _projectRepository;
private readonly IUserService _userService;
private readonly ILogger<EventsController> _logger;
private readonly IFeatureService _featureService;
@@ -29,12 +31,18 @@ public class EventsController : Controller
IEventRepository eventRepository,
ICipherRepository cipherRepository,
ICurrentContext currentContext,
ISecretRepository secretRepository,
IProjectRepository projectRepository,
IUserService userService,
ILogger<EventsController> logger,
IFeatureService featureService)
{
_eventRepository = eventRepository;
_cipherRepository = cipherRepository;
_currentContext = currentContext;
_secretRepository = secretRepository;
_projectRepository = projectRepository;
_userService = userService;
_logger = logger;
_featureService = featureService;
}
@@ -50,35 +58,76 @@ public class EventsController : Controller
[ProducesResponseType(typeof(PagedListResponseModel<EventResponseModel>), (int)HttpStatusCode.OK)]
public async Task<IActionResult> List([FromQuery] EventFilterRequestModel request)
{
if (!_currentContext.OrganizationId.HasValue)
{
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
}
var organizationId = _currentContext.OrganizationId.Value;
var dateRange = request.ToDateRange();
var result = new PagedResult<IEvent>();
if (request.ActingUserId.HasValue)
{
result = await _eventRepository.GetManyByOrganizationActingUserAsync(
_currentContext.OrganizationId.Value, request.ActingUserId.Value, dateRange.Item1, dateRange.Item2,
organizationId, request.ActingUserId.Value, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
else if (request.ItemId.HasValue)
{
var cipher = await _cipherRepository.GetByIdAsync(request.ItemId.Value);
if (cipher != null && cipher.OrganizationId == _currentContext.OrganizationId.Value)
if (cipher != null && cipher.OrganizationId == organizationId)
{
result = await _eventRepository.GetManyByCipherAsync(
cipher, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
}
else if (request.SecretId.HasValue)
{
var secret = await _secretRepository.GetByIdAsync(request.SecretId.Value);
if (secret == null)
{
secret = new Core.SecretsManager.Entities.Secret { Id = request.SecretId.Value, OrganizationId = organizationId };
}
if (secret.OrganizationId == organizationId)
{
result = await _eventRepository.GetManyBySecretAsync(
secret, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
else
{
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
}
}
else if (request.ProjectId.HasValue)
{
var project = await _projectRepository.GetByIdAsync(request.ProjectId.Value);
if (project != null && project.OrganizationId == organizationId)
{
result = await _eventRepository.GetManyByProjectAsync(
project, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
else
{
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
}
}
else
{
result = await _eventRepository.GetManyByOrganizationAsync(
_currentContext.OrganizationId.Value, dateRange.Item1, dateRange.Item2,
organizationId, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
var eventResponses = result.Data.Select(e => new EventResponseModel(e));
var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken);
var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken ?? "");
_logger.LogAggregateData(_featureService, organizationId, response, request);
_logger.LogAggregateData(_featureService, _currentContext.OrganizationId!.Value, response, request);
return new JsonResult(response);
}
}

View File

@@ -24,6 +24,14 @@ public class EventFilterRequestModel
/// </summary>
public Guid? ItemId { get; set; }
/// <summary>
/// The unique identifier of the related secret that the event describes.
/// </summary>
public Guid? SecretId { get; set; }
/// <summary>
/// The unique identifier of the related project that the event describes.
/// </summary>
public Guid? ProjectId { get; set; }
/// <summary>
/// A cursor for use in pagination.
/// </summary>
public string ContinuationToken { get; set; }

View File

@@ -33,7 +33,7 @@
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.31.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
</ItemGroup>

View File

@@ -4,6 +4,7 @@ using Bit.Api.Models.Request;
using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Business;
@@ -24,7 +25,8 @@ namespace Bit.Api.Billing.Controllers;
public class AccountsController(
IUserService userService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserAccountKeysQuery userAccountKeysQuery) : Controller
IUserAccountKeysQuery userAccountKeysQuery,
IFeatureService featureService) : Controller
{
[HttpPost("premium")]
public async Task<PaymentResponseModel> PostPremiumAsync(
@@ -84,17 +86,25 @@ public class AccountsController(
throw new UnauthorizedAccessException();
}
if (!globalSettings.SelfHosted && user.Gateway != null)
// Only cloud-hosted users with payment gateways have subscription and discount information
if (!globalSettings.SelfHosted)
{
if (user.Gateway != null)
{
// Note: PM23341_Milestone_2 is the feature flag for the overall Milestone 2 initiative (PM-23341).
// This specific implementation (PM-26682) adds discount display functionality as part of that initiative.
// The feature flag controls the broader Milestone 2 feature set, not just this specific task.
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
return new SubscriptionResponseModel(user, subscriptionInfo, license);
return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount);
}
else if (!globalSettings.SelfHosted)
else
{
var license = await userService.GenerateLicenseAsync(user);
return new SubscriptionResponseModel(user, license);
}
}
else
{
return new SubscriptionResponseModel(user);

View File

@@ -0,0 +1,35 @@
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization.Requirements;
using Bit.Api.Billing.Attributes;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Bit.Api.Billing.Controllers.VNext;
[Authorize("Application")]
[Route("organizations/{organizationId:guid}/billing/vnext/self-host")]
[SelfHosted(SelfHostedOnly = true)]
public class SelfHostedBillingController(
IGetOrganizationMetadataQuery getOrganizationMetadataQuery) : BaseBillingController
{
[Authorize<MemberOrProviderRequirement>]
[HttpGet("metadata")]
[RequireFeature(FeatureFlagKeys.PM25379_UseNewOrganizationMetadataStructure)]
[InjectOrganization]
public async Task<IResult> GetMetadataAsync([BindNever] Organization organization)
{
var metadata = await getOrganizationMetadataQuery.Run(organization);
if (metadata == null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(metadata);
}
}

View File

@@ -66,7 +66,10 @@ public class HibpController : Controller
}
else if (response.StatusCode == HttpStatusCode.NotFound)
{
return new NotFoundResult();
/* 12/1/2025 - Per the HIBP API, If the domain does not have any email addresses in any breaches,
an HTTP 404 response will be returned. API also specifies that "404 Not found is the account could
not be found and has therefore not been pwned". Per REST semantics we will return 200 OK with empty array. */
return Content("[]", "application/json");
}
else if (response.StatusCode == HttpStatusCode.TooManyRequests && retry)
{

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.KeyManagement.Models.Api.Request;
namespace Bit.Api.KeyManagement.Models.Requests;

View File

@@ -1,6 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Models.Api;
@@ -11,7 +9,17 @@ namespace Bit.Api.Models.Response;
public class SubscriptionResponseModel : ResponseModel
{
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license)
/// <param name="user">The user entity containing storage and premium subscription information</param>
/// <param name="subscription">Subscription information retrieved from the payment provider (Stripe/Braintree)</param>
/// <param name="license">The user's license containing expiration and feature entitlements</param>
/// <param name="includeMilestone2Discount">
/// Whether to include discount information in the response.
/// Set to true when the PM23341_Milestone_2 feature flag is enabled AND
/// you want to expose Milestone 2 discount information to the client.
/// The discount will only be included if it matches the specific Milestone 2 coupon ID.
/// </param>
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license, bool includeMilestone2Discount = false)
: base("subscription")
{
Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
@@ -22,9 +30,14 @@ public class SubscriptionResponseModel : ResponseModel
MaxStorageGb = user.MaxStorageGb;
License = license;
Expiration = License.Expires;
// Only display the Milestone 2 subscription discount on the subscription page.
CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription.CustomerDiscount)
? new BillingCustomerDiscount(subscription.CustomerDiscount!)
: null;
}
public SubscriptionResponseModel(User user, UserLicense license = null)
public SubscriptionResponseModel(User user, UserLicense? license = null)
: base("subscription")
{
StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null;
@@ -38,21 +51,109 @@ public class SubscriptionResponseModel : ResponseModel
}
}
public string StorageName { get; set; }
public string? StorageName { get; set; }
public double? StorageGb { get; set; }
public short? MaxStorageGb { get; set; }
public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; }
public BillingSubscription Subscription { get; set; }
public UserLicense License { get; set; }
public BillingSubscriptionUpcomingInvoice? UpcomingInvoice { get; set; }
public BillingSubscription? Subscription { get; set; }
/// <summary>
/// Customer discount information from Stripe for the Milestone 2 subscription discount.
/// Only includes the specific Milestone 2 coupon (cm3nHfO1) when it's a perpetual discount (no expiration).
/// This is for display purposes only and does not affect Stripe's automatic discount application.
/// Other discounts may still apply in Stripe billing but are not included in this response.
/// <para>
/// Null when:
/// - The PM23341_Milestone_2 feature flag is disabled
/// - There is no active discount
/// - The discount coupon ID doesn't match the Milestone 2 coupon (cm3nHfO1)
/// - The instance is self-hosted
/// </para>
/// </summary>
public BillingCustomerDiscount? CustomerDiscount { get; set; }
public UserLicense? License { get; set; }
public DateTime? Expiration { get; set; }
/// <summary>
/// Determines whether the Milestone 2 discount should be included in the response.
/// </summary>
/// <param name="includeMilestone2Discount">Whether the feature flag is enabled and discount should be considered.</param>
/// <param name="customerDiscount">The customer discount from subscription info, if any.</param>
/// <returns>True if the discount should be included; false otherwise.</returns>
private static bool ShouldIncludeMilestone2Discount(
bool includeMilestone2Discount,
SubscriptionInfo.BillingCustomerDiscount? customerDiscount)
{
return includeMilestone2Discount &&
customerDiscount != null &&
customerDiscount.Id == StripeConstants.CouponIDs.Milestone2SubscriptionDiscount &&
customerDiscount.Active;
}
}
public class BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
/// <summary>
/// Customer discount information from Stripe billing.
/// </summary>
public class BillingCustomerDiscount
{
public string Id { get; } = discount.Id;
public bool Active { get; } = discount.Active;
public decimal? PercentOff { get; } = discount.PercentOff;
public List<string> AppliesTo { get; } = discount.AppliesTo;
/// <summary>
/// The Stripe coupon ID (e.g., "cm3nHfO1").
/// </summary>
public string? Id { get; }
/// <summary>
/// Whether the discount is a recurring/perpetual discount with no expiration date.
/// <para>
/// This property is true only when the discount has no end date, meaning it applies
/// indefinitely to all future renewals. This is a product decision for Milestone 2
/// to only display perpetual discounts in the UI.
/// </para>
/// <para>
/// Note: This does NOT indicate whether the discount is "currently active" in the billing sense.
/// A discount with a future end date is functionally active and will be applied by Stripe,
/// but this property will be false because it has an expiration date.
/// </para>
/// </summary>
public bool Active { get; }
/// <summary>
/// Percentage discount applied to the subscription (e.g., 20.0 for 20% off).
/// Null if this is an amount-based discount.
/// </summary>
public decimal? PercentOff { get; }
/// <summary>
/// Fixed amount discount in USD (e.g., 14.00 for $14 off).
/// Converted from Stripe's cent-based values (1400 cents → $14.00).
/// Null if this is a percentage-based discount.
/// Note: Stripe stores amounts in the smallest currency unit. This value is always in USD.
/// </summary>
public decimal? AmountOff { get; }
/// <summary>
/// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]).
/// <para>
/// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe).
/// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe).
/// Non-empty list: discount applies only to the specified product IDs.
/// </para>
/// </summary>
public IReadOnlyList<string>? AppliesTo { get; }
/// <summary>
/// Creates a BillingCustomerDiscount from a SubscriptionInfo.BillingCustomerDiscount.
/// </summary>
/// <param name="discount">The discount to convert. Must not be null.</param>
/// <exception cref="ArgumentNullException">Thrown when discount is null.</exception>
public BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
{
ArgumentNullException.ThrowIfNull(discount);
Id = discount.Id;
Active = discount.Active;
PercentOff = discount.PercentOff;
AmountOff = discount.AmountOff;
AppliesTo = discount.AppliesTo;
}
}
public class BillingSubscription
@@ -83,10 +184,10 @@ public class BillingSubscription
public DateTime? PeriodEndDate { get; set; }
public DateTime? CancelledDate { get; set; }
public bool CancelAtEndDate { get; set; }
public string Status { get; set; }
public string? Status { get; set; }
public bool Cancelled { get; set; }
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
public string CollectionMethod { get; set; }
public string? CollectionMethod { get; set; }
public DateTime? SuspensionDate { get; set; }
public DateTime? UnpaidPeriodEndDate { get; set; }
public int? GracePeriod { get; set; }
@@ -104,11 +205,11 @@ public class BillingSubscription
AddonSubscriptionItem = item.AddonSubscriptionItem;
}
public string ProductId { get; set; }
public string Name { get; set; }
public string? ProductId { get; set; }
public string? Name { get; set; }
public decimal Amount { get; set; }
public int Quantity { get; set; }
public string Interval { get; set; }
public string? Interval { get; set; }
public bool SponsoredSubscriptionItem { get; set; }
public bool AddonSubscriptionItem { get; set; }
}

View File

@@ -1,9 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using AspNetCoreRateLimit;
using Bit.Core.Utilities;
using Microsoft.IdentityModel.Tokens;
using Bit.Core.Utilities;
namespace Bit.Api;
@@ -17,32 +12,8 @@ public class Program
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Exception != null &&
(e.Exception.GetType() == typeof(SecurityTokenValidationException) ||
e.Exception.Message == "Bad security stamp."))
{
return false;
}
if (
context.Contains(typeof(IpRateLimitMiddleware).FullName))
{
return e.Level >= globalSettings.MinLogLevel.ApiSettings.IpRateLimit;
}
if (context.Contains("Duende.IdentityServer.Validation.TokenValidator") ||
context.Contains("Duende.IdentityServer.Validation.TokenRequestValidator"))
{
return e.Level >= globalSettings.MinLogLevel.ApiSettings.IdentityToken;
}
return e.Level >= globalSettings.MinLogLevel.ApiSettings.Default;
}));
})
.AddSerilogFileLogging()
.Build()
.Run();
}

View File

@@ -216,7 +216,7 @@ public class Startup
config.Conventions.Add(new PublicApiControllersModelConvention());
});
services.AddSwagger(globalSettings, Environment);
services.AddSwaggerGen(globalSettings, Environment);
Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted);
services.AddHostedService<Jobs.JobsHostedService>();
@@ -234,12 +234,10 @@ public class Startup
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings,
ILogger<Startup> logger)
{
IdentityModelEventSource.ShowPII = true;
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();
@@ -294,17 +292,59 @@ public class Startup
});
// Add Swagger
// Note that the swagger.json generation is configured in the call to AddSwaggerGen above.
if (Environment.IsDevelopment() || globalSettings.SelfHosted)
{
// adds the middleware to serve the swagger.json while the server is running
app.UseSwagger(config =>
{
config.RouteTemplate = "specs/{documentName}/swagger.json";
// Remove all Bitwarden cloud servers and only register the local server
config.PreSerializeFilters.Add((swaggerDoc, httpReq) =>
swaggerDoc.Servers = new List<OpenApiServer>
{
new OpenApiServer { Url = globalSettings.BaseServiceUri.Api }
swaggerDoc.Servers.Clear();
swaggerDoc.Servers.Add(new OpenApiServer
{
Url = globalSettings.BaseServiceUri.Api,
});
swaggerDoc.Components.SecuritySchemes.Clear();
swaggerDoc.Components.SecuritySchemes.Add("oauth2-client-credentials", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
ClientCredentials = new OpenApiOAuthFlow
{
TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"),
Scopes = new Dictionary<string, string>
{
{ ApiScopes.ApiOrganization, "Organization APIs" }
}
}
}
});
swaggerDoc.SecurityRequirements.Clear();
swaggerDoc.SecurityRequirements.Add(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "oauth2-client-credentials"
}
},
[ApiScopes.ApiOrganization]
}
});
});
});
// adds the middleware to display the web UI
app.UseSwaggerUI(config =>
{
config.DocumentTitle = "Bitwarden API Documentation";

View File

@@ -1,6 +1,5 @@
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.Tools.Authorization;
using Bit.Core.Auth.IdentityServer;
using Bit.Core.PhishingDomainFeatures;
using Bit.Core.PhishingDomainFeatures.Interfaces;
using Bit.Core.Repositories;
@@ -10,6 +9,7 @@ using Bit.Core.Utilities;
using Bit.Core.Vault.Authorization.SecurityTasks;
using Bit.SharedWeb.Health;
using Bit.SharedWeb.Swagger;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi.Models;
@@ -17,7 +17,10 @@ namespace Bit.Api.Utilities;
public static class ServiceCollectionExtensions
{
public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment)
/// <summary>
/// Configures the generation of swagger.json OpenAPI spec.
/// </summary>
public static void AddSwaggerGen(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment)
{
services.AddSwaggerGen(config =>
{
@@ -36,6 +39,8 @@ public static class ServiceCollectionExtensions
organizations tools for managing members, collections, groups, event logs, and policies.
If you are looking for the Vault Management API, refer instead to
[this document](https://bitwarden.com/help/vault-management-api/).
**Note:** your authorization must match the server you have selected.
""",
License = new OpenApiLicense
{
@@ -46,36 +51,20 @@ public static class ServiceCollectionExtensions
config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" });
config.AddSecurityDefinition("oauth2-client-credentials", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
ClientCredentials = new OpenApiOAuthFlow
{
TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"),
Scopes = new Dictionary<string, string>
{
{ ApiScopes.ApiOrganization, "Organization APIs" },
},
}
},
});
// Configure Bitwarden cloud US and EU servers. These will appear in the swagger.json build artifact
// used for our help center. These are overwritten with the local server when running in self-hosted
// or dev mode (see Api Startup.cs).
config.AddSwaggerServerWithSecurity(
serverId: "US_server",
serverUrl: "https://api.bitwarden.com",
identityTokenUrl: "https://identity.bitwarden.com/connect/token",
serverDescription: "US server");
config.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "oauth2-client-credentials"
},
},
new[] { ApiScopes.ApiOrganization }
}
});
config.AddSwaggerServerWithSecurity(
serverId: "EU_server",
serverUrl: "https://api.bitwarden.eu",
identityTokenUrl: "https://identity.bitwarden.eu/connect/token",
serverDescription: "EU server");
config.DescribeAllParametersInCamelCase();
// config.UseReferencedDefinitionsForEnums();

View File

@@ -402,8 +402,9 @@ public class CiphersController : Controller
{
var org = _currentContext.GetOrganization(organizationId);
// If we're not an "admin" or if we're not a provider user we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
// If we're not an "admin" we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.EditAnyCollection: true }))
{
return false;
}
@@ -416,8 +417,9 @@ public class CiphersController : Controller
{
var org = _currentContext.GetOrganization(organizationId);
// If we're not an "admin" or if we're a provider user we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
// If we're not an "admin" we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.EditAnyCollection: true }))
{
return false;
}
@@ -755,11 +757,6 @@ public class CiphersController : Controller
}
}
if (cipher.ArchivedDate.HasValue)
{
throw new BadRequestException("Cannot move an archived item to an organization.");
}
ValidateClientVersionForFido2CredentialSupport(cipher);
var original = cipher.Clone();
@@ -1269,11 +1266,6 @@ public class CiphersController : Controller
_logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", cipher.Id, userId, cipher.EncryptedFor);
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
}
if (cipher.ArchivedDate.HasValue)
{
throw new BadRequestException("Cannot move archived items to an organization.");
}
}
var shareCiphers = new List<(CipherDetails, DateTime?)>();
@@ -1286,11 +1278,6 @@ public class CiphersController : Controller
ValidateClientVersionForFido2CredentialSupport(existingCipher);
if (existingCipher.ArchivedDate.HasValue)
{
throw new BadRequestException("Cannot move archived items to an organization.");
}
shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate));
}
@@ -1420,11 +1407,9 @@ public class CiphersController : Controller
throw new NotFoundException();
}
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (stream) =>
{
await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData, lastKnownRevisionDate);
await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData);
});
}
@@ -1523,13 +1508,10 @@ public class CiphersController : Controller
throw new NotFoundException();
}
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (stream, fileName, key) =>
{
await _cipherService.CreateAttachmentShareAsync(cipher, stream, fileName, key,
Request.ContentLength.GetValueOrDefault(0), attachmentId, organizationId, lastKnownRevisionDate);
Request.ContentLength.GetValueOrDefault(0), attachmentId, organizationId);
});
}

View File

@@ -41,6 +41,7 @@
"phishingDomain": {
"updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt",
"checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256"
}
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}
}

View File

@@ -32,9 +32,6 @@
"send": {
"connectionString": "SECRET"
},
"sentry": {
"dsn": "SECRET"
},
"notificationHub": {
"connectionString": "SECRET",
"hubName": "SECRET"

View File

@@ -158,6 +158,7 @@ public class FreshsalesController : Controller
planName = "Free";
return true;
case PlanType.FamiliesAnnually:
case PlanType.FamiliesAnnually2025:
case PlanType.FamiliesAnnually2019:
planName = "Families";
return true;

View File

@@ -0,0 +1,36 @@
using Bit.Billing.Jobs;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Billing.Controllers;
[Route("jobs")]
[SelfHosted(NotSelfHostedOnly = true)]
[RequireLowerEnvironment]
public class JobsController(
JobsHostedService jobsHostedService) : Controller
{
[HttpPost("run/{jobName}")]
public async Task<IActionResult> RunJobAsync(string jobName)
{
if (jobName == nameof(ReconcileAdditionalStorageJob))
{
await jobsHostedService.RunJobAdHocAsync<ReconcileAdditionalStorageJob>();
return Ok(new { message = $"Job {jobName} scheduled successfully" });
}
return BadRequest(new { error = $"Unknown job name: {jobName}" });
}
[HttpPost("stop/{jobName}")]
public async Task<IActionResult> StopJobAsync(string jobName)
{
if (jobName == nameof(ReconcileAdditionalStorageJob))
{
await jobsHostedService.InterruptAdHocJobAsync<ReconcileAdditionalStorageJob>();
return Ok(new { message = $"Job {jobName} queued for cancellation" });
}
return BadRequest(new { error = $"Unknown job name: {jobName}" });
}
}

View File

@@ -10,4 +10,13 @@ public class AliveJob(ILogger<AliveJob> logger) : BaseJob(logger)
_logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Billing service is alive!");
return Task.FromResult(0);
}
public static ITrigger GetTrigger()
{
return TriggerBuilder.Create()
.WithIdentity("EveryTopOfTheHourTrigger")
.StartNow()
.WithCronSchedule("0 0 * * * ?")
.Build();
}
}

View File

@@ -1,29 +1,27 @@
using Bit.Core.Jobs;
using Bit.Core.Exceptions;
using Bit.Core.Jobs;
using Bit.Core.Settings;
using Quartz;
namespace Bit.Billing.Jobs;
public class JobsHostedService : BaseJobsHostedService
{
public JobsHostedService(
public class JobsHostedService(
GlobalSettings globalSettings,
IServiceProvider serviceProvider,
ILogger<JobsHostedService> logger,
ILogger<JobListener> listenerLogger)
: base(globalSettings, serviceProvider, logger, listenerLogger) { }
ILogger<JobListener> listenerLogger,
ISchedulerFactory schedulerFactory)
: BaseJobsHostedService(globalSettings, serviceProvider, logger, listenerLogger)
{
private List<JobKey> AdHocJobKeys { get; } = [];
private IScheduler? _adHocScheduler;
public override async Task StartAsync(CancellationToken cancellationToken)
{
var everyTopOfTheHourTrigger = TriggerBuilder.Create()
.WithIdentity("EveryTopOfTheHourTrigger")
.StartNow()
.WithCronSchedule("0 0 * * * ?")
.Build();
Jobs = new List<Tuple<Type, ITrigger>>
{
new Tuple<Type, ITrigger>(typeof(AliveJob), everyTopOfTheHourTrigger)
new(typeof(AliveJob), AliveJob.GetTrigger()),
new(typeof(ReconcileAdditionalStorageJob), ReconcileAdditionalStorageJob.GetTrigger())
};
await base.StartAsync(cancellationToken);
@@ -33,5 +31,54 @@ public class JobsHostedService : BaseJobsHostedService
{
services.AddTransient<AliveJob>();
services.AddTransient<SubscriptionCancellationJob>();
services.AddTransient<ReconcileAdditionalStorageJob>();
// add this service as a singleton so we can inject it where needed
services.AddSingleton<JobsHostedService>();
services.AddHostedService(sp => sp.GetRequiredService<JobsHostedService>());
}
public async Task InterruptAdHocJobAsync<T>(CancellationToken cancellationToken = default) where T : class, IJob
{
if (_adHocScheduler == null)
{
throw new InvalidOperationException("AdHocScheduler is null, cannot interrupt ad-hoc job.");
}
var jobKey = AdHocJobKeys.FirstOrDefault(j => j.Name == typeof(T).ToString());
if (jobKey == null)
{
throw new NotFoundException($"Cannot find job key: {typeof(T)}, not running?");
}
logger.LogInformation("CANCELLING ad-hoc job with key: {JobKey}", jobKey);
AdHocJobKeys.Remove(jobKey);
await _adHocScheduler.Interrupt(jobKey, cancellationToken);
}
public async Task RunJobAdHocAsync<T>(CancellationToken cancellationToken = default) where T : class, IJob
{
_adHocScheduler ??= await schedulerFactory.GetScheduler(cancellationToken);
var jobKey = new JobKey(typeof(T).ToString());
var currentlyExecuting = await _adHocScheduler.GetCurrentlyExecutingJobs(cancellationToken);
if (currentlyExecuting.Any(j => j.JobDetail.Key.Equals(jobKey)))
{
throw new InvalidOperationException($"Job {jobKey} is already running");
}
AdHocJobKeys.Add(jobKey);
var job = JobBuilder.Create<T>()
.WithIdentity(jobKey)
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity(typeof(T).ToString())
.StartNow()
.Build();
logger.LogInformation("Scheduling ad-hoc job with key: {JobKey}", jobKey);
await _adHocScheduler.ScheduleJob(job, trigger, cancellationToken);
}
}

View File

@@ -0,0 +1,207 @@
using System.Globalization;
using System.Text.Json;
using Bit.Billing.Services;
using Bit.Core;
using Bit.Core.Billing.Constants;
using Bit.Core.Jobs;
using Bit.Core.Services;
using Quartz;
using Stripe;
namespace Bit.Billing.Jobs;
public class ReconcileAdditionalStorageJob(
IStripeFacade stripeFacade,
ILogger<ReconcileAdditionalStorageJob> logger,
IFeatureService featureService) : BaseJob(logger)
{
private const string _storageGbMonthlyPriceId = "storage-gb-monthly";
private const string _storageGbAnnuallyPriceId = "storage-gb-annually";
private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually";
private const int _storageGbToRemove = 4;
protected override async Task ExecuteJobAsync(IJobExecutionContext context)
{
if (!featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob))
{
logger.LogInformation("Skipping ReconcileAdditionalStorageJob, feature flag off.");
return;
}
var liveMode = featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode);
// Execution tracking
var subscriptionsFound = 0;
var subscriptionsUpdated = 0;
var subscriptionsWithErrors = 0;
var failures = new List<string>();
logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode);
var priceIds = new[] { _storageGbMonthlyPriceId, _storageGbAnnuallyPriceId, _personalStorageGbAnnuallyPriceId };
foreach (var priceId in priceIds)
{
var options = new SubscriptionListOptions
{
Limit = 100,
Status = StripeConstants.SubscriptionStatus.Active,
Price = priceId
};
await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options))
{
if (context.CancellationToken.IsCancellationRequested)
{
logger.LogWarning(
"Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " +
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
subscriptionsFound,
liveMode
? subscriptionsUpdated
: $"(In live mode, would have updated) {subscriptionsUpdated}",
subscriptionsWithErrors,
failures.Count > 0
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
: string.Empty
);
return;
}
if (subscription == null)
{
continue;
}
logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id);
subscriptionsFound++;
if (subscription.Metadata?.TryGetValue(StripeConstants.MetadataKeys.StorageReconciled2025, out var dateString) == true)
{
if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out var dateProcessed))
{
logger.LogInformation("Skipping subscription {SubscriptionId} - already processed on {Date}",
subscription.Id,
dateProcessed.ToString("f"));
continue;
}
}
var updateOptions = BuildSubscriptionUpdateOptions(subscription, priceId);
if (updateOptions == null)
{
logger.LogInformation("Skipping subscription {SubscriptionId} - no updates needed", subscription.Id);
continue;
}
subscriptionsUpdated++;
if (!liveMode)
{
logger.LogInformation(
"Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}",
subscription.Id,
Environment.NewLine,
JsonSerializer.Serialize(updateOptions));
continue;
}
try
{
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id);
}
catch (Exception ex)
{
subscriptionsWithErrors++;
failures.Add($"Subscription {subscription.Id}: {ex.Message}");
logger.LogError(ex, "Failed to update subscription {SubscriptionId}: {ErrorMessage}",
subscription.Id, ex.Message);
}
}
}
logger.LogInformation(
"ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " +
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
subscriptionsFound,
liveMode
? subscriptionsUpdated
: $"(In live mode, would have updated) {subscriptionsUpdated}",
subscriptionsWithErrors,
failures.Count > 0
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
: string.Empty
);
}
private SubscriptionUpdateOptions? BuildSubscriptionUpdateOptions(
Subscription subscription,
string targetPriceId)
{
if (subscription.Items?.Data == null)
{
return null;
}
var updateOptions = new SubscriptionUpdateOptions
{
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o")
},
Items = []
};
var hasUpdates = false;
foreach (var item in subscription.Items.Data.Where(item => item?.Price?.Id == targetPriceId))
{
hasUpdates = true;
var currentQuantity = item.Quantity;
if (currentQuantity > _storageGbToRemove)
{
var newQuantity = currentQuantity - _storageGbToRemove;
logger.LogInformation(
"Subscription {SubscriptionId}: reducing quantity from {CurrentQuantity} to {NewQuantity} for price {PriceId}",
subscription.Id,
currentQuantity,
newQuantity,
item.Price.Id);
updateOptions.Items.Add(new SubscriptionItemOptions
{
Id = item.Id,
Quantity = newQuantity
});
}
else
{
logger.LogInformation("Subscription {SubscriptionId}: deleting storage item with quantity {CurrentQuantity} for price {PriceId}",
subscription.Id,
currentQuantity,
item.Price.Id);
updateOptions.Items.Add(new SubscriptionItemOptions
{
Id = item.Id,
Deleted = true
});
}
}
return hasUpdates ? updateOptions : null;
}
public static ITrigger GetTrigger()
{
return TriggerBuilder.Create()
.WithIdentity("EveryMorningTrigger")
.StartNow()
.WithCronSchedule("0 0 16 * * ?") // 10am CST daily; the pods execute in UTC time
.Build();
}
}

View File

@@ -11,25 +11,8 @@ public class Program
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (context.StartsWith("\"Bit.Billing.Jobs") || context.StartsWith("\"Bit.Core.Jobs"))
{
return e.Level >= globalSettings.MinLogLevel.BillingSettings.Jobs;
}
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
{
return false;
}
return e.Level >= globalSettings.MinLogLevel.BillingSettings.Default;
}));
})
.AddSerilogFileLogging()
.Build()
.Run();
}

View File

@@ -78,6 +78,11 @@ public interface IStripeFacade
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
IAsyncEnumerable<Subscription> ListSubscriptionsAutoPagingAsync(
SubscriptionListOptions options = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Subscription> GetSubscription(
string subscriptionId,
SubscriptionGetOptions subscriptionGetOptions = null,
@@ -111,4 +116,10 @@ public interface IStripeFacade
TestClockGetOptions testClockGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Coupon> GetCoupon(
string couponId,
CouponGetOptions couponGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
}

View File

@@ -3,12 +3,12 @@
using Bit.Billing.Constants;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Braintree;
using Stripe;
using Customer = Stripe.Customer;
@@ -112,7 +112,7 @@ public class StripeEventUtilityService : IStripeEventUtilityService
}
public bool IsSponsoredSubscription(Subscription subscription) =>
StaticStore.SponsoredPlans
SponsoredPlans.All
.Any(p => subscription.Items
.Any(i => i.Plan.Id == p.StripePlanId));

View File

@@ -18,6 +18,7 @@ public class StripeFacade : IStripeFacade
private readonly DiscountService _discountService = new();
private readonly SetupIntentService _setupIntentService = new();
private readonly TestClockService _testClockService = new();
private readonly CouponService _couponService = new();
public async Task<Charge> GetCharge(
string chargeId,
@@ -98,6 +99,12 @@ public class StripeFacade : IStripeFacade
CancellationToken cancellationToken = default) =>
await _subscriptionService.ListAsync(options, requestOptions, cancellationToken);
public IAsyncEnumerable<Subscription> ListSubscriptionsAutoPagingAsync(
SubscriptionListOptions options = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
_subscriptionService.ListAutoPagingAsync(options, requestOptions, cancellationToken);
public async Task<Subscription> GetSubscription(
string subscriptionId,
SubscriptionGetOptions subscriptionGetOptions = null,
@@ -137,4 +144,11 @@ public class StripeFacade : IStripeFacade
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
_testClockService.GetAsync(testClockId, testClockGetOptions, requestOptions, cancellationToken);
public Task<Coupon> GetCoupon(
string couponId,
CouponGetOptions couponGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
_couponService.GetAsync(couponId, couponGetOptions, requestOptions, cancellationToken);
}

View File

@@ -1,7 +1,5 @@
using System.Globalization;
using Bit.Billing.Constants;
using Bit.Billing.Constants;
using Bit.Billing.Jobs;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
@@ -134,11 +132,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
}
case StripeSubscriptionStatus.Active when providerId.HasValue:
{
var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
if (!providerPortalTakeover)
{
break;
}
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
if (provider != null)
{
@@ -321,13 +314,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
Event parsedEvent,
Subscription currentSubscription)
{
var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
if (!providerPortalTakeover)
{
return;
}
var provider = await _providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
@@ -343,7 +329,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
{
var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() as Subscription;
var updateIsSubscriptionGoingUnpaid = previousSubscription is
if (previousSubscription is
{
Status:
StripeSubscriptionStatus.Trialing or
@@ -353,12 +339,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
{
Status: StripeSubscriptionStatus.Unpaid,
LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create"
};
var updateIsManualSuspensionViaMetadata = CheckForManualSuspensionViaMetadata(
previousSubscription, currentSubscription);
if (updateIsSubscriptionGoingUnpaid || updateIsManualSuspensionViaMetadata)
})
{
if (currentSubscription.TestClock != null)
{
@@ -369,14 +350,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) };
if (updateIsManualSuspensionViaMetadata)
{
subscriptionUpdateOptions.Metadata = new Dictionary<string, string>
{
["suspended_provider_via_webhook_at"] = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)
};
}
await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions);
}
}
@@ -399,37 +372,4 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
}
}
}
private static bool CheckForManualSuspensionViaMetadata(
Subscription? previousSubscription,
Subscription currentSubscription)
{
/*
* When metadata on a subscription is updated, we'll receive an event that has:
* Previous Metadata: { newlyAddedKey: null }
* Current Metadata: { newlyAddedKey: newlyAddedValue }
*
* As such, our check for a manual suspension must ensure that the 'previous_attributes' does contain the
* 'metadata' property, but also that the "suspend_provider" key in that metadata is set to null.
*
* If we don't do this and instead do a null coalescing check on 'previous_attributes?.metadata?.TryGetValue',
* we'll end up marking an event where 'previous_attributes.metadata' = null (which could be any subscription update
* that does not update the metadata) the same as a manual suspension.
*/
const string key = "suspend_provider";
if (previousSubscription is not { Metadata: not null } ||
!previousSubscription.Metadata.TryGetValue(key, out var previousValue))
{
return false;
}
if (previousValue == null)
{
return !string.IsNullOrEmpty(
currentSubscription.Metadata.TryGetValue(key, out var currentValue) ? currentValue : null);
}
return false;
}
}

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Globalization;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
@@ -10,14 +8,23 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Entities;
using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
using Bit.Core.Models.Mail.Billing.Renewal.Premium;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Stripe;
using Event = Stripe.Event;
using Plan = Bit.Core.Models.StaticStore.Plan;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
namespace Bit.Billing.Services.Implementations;
using static StripeConstants;
public class UpcomingInvoiceHandler(
IGetPaymentMethodQuery getPaymentMethodQuery,
ILogger<StripeEventProcessor> logger,
@@ -29,7 +36,9 @@ public class UpcomingInvoiceHandler(
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IUserRepository userRepository,
IValidateSponsorshipCommand validateSponsorshipCommand)
IValidateSponsorshipCommand validateSponsorshipCommand,
IMailer mailer,
IFeatureService featureService)
: IUpcomingInvoiceHandler
{
public async Task HandleAsync(Event parsedEvent)
@@ -37,7 +46,8 @@ public class UpcomingInvoiceHandler(
var invoice = await stripeEventService.GetInvoice(parsedEvent);
var customer =
await stripeFacade.GetCustomer(invoice.CustomerId, new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] });
await stripeFacade.GetCustomer(invoice.CustomerId,
new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] });
var subscription = customer.Subscriptions.FirstOrDefault();
@@ -50,17 +60,74 @@ public class UpcomingInvoiceHandler(
if (organizationId.HasValue)
{
var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
await HandleOrganizationUpcomingInvoiceAsync(
organizationId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
else if (userId.HasValue)
{
await HandlePremiumUsersUpcomingInvoiceAsync(
userId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
else if (providerId.HasValue)
{
await HandleProviderUpcomingInvoiceAsync(
providerId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
}
#region Organizations
private async Task HandleOrganizationUpcomingInvoiceAsync(
Guid organizationId,
Event @event,
Invoice invoice,
Customer customer,
Subscription subscription)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
logger.LogWarning("Could not find Organization ({OrganizationID}) for '{EventType}' event ({EventID})",
organizationId, @event.Type, @event.Id);
return;
}
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, @event.Id);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3);
var subscriptionAligned = await AlignOrganizationSubscriptionConcernsAsync(
organization,
@event,
subscription,
plan,
milestone3);
/*
* Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue
* with processing.
*/
if (subscriptionAligned)
{
return;
}
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
// Don't send the upcoming invoice email unless the organization's on an annual plan.
if (!plan.IsAnnual)
{
return;
@@ -68,7 +135,8 @@ public class UpcomingInvoiceHandler(
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
{
var sponsorshipIsValid = await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
var sponsorshipIsValid =
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId);
if (!sponsorshipIsValid)
{
@@ -80,29 +148,209 @@ public class UpcomingInvoiceHandler(
}
}
await SendUpcomingInvoiceEmailsAsync(new List<string> { organization.BillingEmail }, invoice);
/*
* TODO: https://bitwarden.atlassian.net/browse/PM-4862
* Disabling this as part of a hot fix. It needs to check whether the organization
* belongs to a Reseller provider and only send an email to the organization owners if it does.
* It also requires a new email template as the current one contains too much billing information.
*/
// var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id);
// await SendEmails(ownerEmails);
await SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice);
}
else if (userId.HasValue)
private async Task AlignOrganizationTaxConcernsAsync(
Organization organization,
Subscription subscription,
Customer customer,
string eventId)
{
var user = await userRepository.GetByIdAsync(userId.Value);
var nonUSBusinessUse =
organization.PlanType.GetProductTier() != ProductTierType.Families &&
customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
if (nonUSBusinessUse && customer.TaxExempt != TaxExempt.Reverse)
{
try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) to reverse tax exemption while processing event with ID {EventID}",
organization.Id,
eventId);
}
}
if (!subscription.AutomaticTax.Enabled)
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) subscription to automatic tax while processing event with ID {EventID}",
organization.Id,
eventId);
}
}
}
/// <summary>
/// Aligns the organization's subscription details with the specified plan and milestone requirements.
/// </summary>
/// <param name="organization">The organization whose subscription is being updated.</param>
/// <param name="event">The Stripe event associated with this operation.</param>
/// <param name="subscription">The organization's subscription.</param>
/// <param name="plan">The organization's current plan.</param>
/// <param name="milestone3">A flag indicating whether the third milestone is enabled.</param>
/// <returns>Whether the operation resulted in an updated subscription.</returns>
private async Task<bool> AlignOrganizationSubscriptionConcernsAsync(
Organization organization,
Event @event,
Subscription subscription,
Plan plan,
bool milestone3)
{
// currently these are the only plans that need aligned and both require the same flag and share most of the logic
if (!milestone3 || plan.Type is not (PlanType.FamiliesAnnually2019 or PlanType.FamiliesAnnually2025))
{
return false;
}
var passwordManagerItem =
subscription.Items.FirstOrDefault(item => item.Price.Id == plan.PasswordManager.StripePlanId);
if (passwordManagerItem == null)
{
logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})",
organization.Id, @event.Type, @event.Id);
return false;
}
var familiesPlan = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
organization.PlanType = familiesPlan.Type;
organization.Plan = familiesPlan.Name;
organization.UsersGetPremium = familiesPlan.UsersGetPremium;
organization.Seats = familiesPlan.PasswordManager.BaseSeats;
var options = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Price = familiesPlan.PasswordManager.StripePlanId
}
],
ProrationBehavior = ProrationBehavior.None
};
if (plan.Type == PlanType.FamiliesAnnually2019)
{
options.Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount }
];
var premiumAccessAddOnItem = subscription.Items.FirstOrDefault(item =>
item.Price.Id == plan.PasswordManager.StripePremiumAccessPlanId);
if (premiumAccessAddOnItem != null)
{
options.Items.Add(new SubscriptionItemOptions
{
Id = premiumAccessAddOnItem.Id,
Deleted = true
});
}
var seatAddOnItem = subscription.Items.FirstOrDefault(item => item.Price.Id == "personal-org-seat-annually");
if (seatAddOnItem != null)
{
options.Items.Add(new SubscriptionItemOptions
{
Id = seatAddOnItem.Id,
Deleted = true
});
}
}
try
{
await organizationRepository.ReplaceAsync(organization);
await stripeFacade.UpdateSubscription(subscription.Id, options);
await SendFamiliesRenewalEmailAsync(organization, familiesPlan, plan);
return true;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to align subscription concerns for Organization ({OrganizationID}) while processing '{EventType}' event ({EventID})",
organization.Id,
@event.Type,
@event.Id);
return false;
}
}
#endregion
#region Premium Users
private async Task HandlePremiumUsersUpcomingInvoiceAsync(
Guid userId,
Event @event,
Invoice invoice,
Customer customer,
Subscription subscription)
{
var user = await userRepository.GetByIdAsync(userId);
if (user == null)
{
logger.LogWarning("Could not find User ({UserID}) for '{EventType}' event ({EventID})",
userId, @event.Type, @event.Id);
return;
}
if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation())
await AlignPremiumUsersTaxConcernsAsync(user, @event, customer, subscription);
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
if (milestone2Feature)
{
var subscriptionAligned = await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription);
/*
* Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue
* with processing.
*/
if (subscriptionAligned)
{
return;
}
}
if (user.Premium)
{
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
}
}
private async Task AlignPremiumUsersTaxConcernsAsync(
User user,
Event @event,
Customer customer,
Subscription subscription)
{
if (!subscription.AutomaticTax.Enabled && customer.HasRecognizedTaxLocation())
{
try
{
@@ -118,48 +366,130 @@ public class UpcomingInvoiceHandler(
exception,
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
user.Id,
parsedEvent.Id);
@event.Id);
}
}
}
if (user.Premium)
private async Task<bool> AlignPremiumUsersSubscriptionConcernsAsync(
User user,
Event @event,
Subscription subscription)
{
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
var premiumItem = subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually);
if (premiumItem == null)
{
logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})",
user.Id, @event.Type, @event.Id);
return false;
}
try
{
var plan = await pricingClient.GetAvailablePremiumPlan();
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions { Id = premiumItem.Id, Price = plan.Seat.StripePriceId }
],
Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }
],
ProrationBehavior = ProrationBehavior.None
});
await SendPremiumRenewalEmailAsync(user, plan);
return true;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}",
user.Id,
@event.Id);
return false;
}
}
else if (providerId.HasValue)
#endregion
#region Providers
private async Task HandleProviderUpcomingInvoiceAsync(
Guid providerId,
Event @event,
Invoice invoice,
Customer customer,
Subscription subscription)
{
var provider = await providerRepository.GetByIdAsync(providerId.Value);
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogWarning("Could not find Provider ({ProviderID}) for '{EventType}' event ({EventID})",
providerId, @event.Type, @event.Id);
return;
}
await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id);
await AlignProviderTaxConcernsAsync(provider, subscription, customer, @event.Id);
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId.Value);
}
}
private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
if (!string.IsNullOrEmpty(provider.BillingEmail))
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId);
}
}
var items = invoice.Lines.Select(i => i.Description).ToList();
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
private async Task AlignProviderTaxConcernsAsync(
Provider provider,
Subscription subscription,
Customer customer,
string eventId)
{
await mailService.SendInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
items,
true);
if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
customer.TaxExempt != TaxExempt.Reverse)
{
try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) to reverse tax exemption while processing event with ID {EventID}",
provider.Id,
eventId);
}
}
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice, Subscription subscription, Guid providerId)
if (!subscription.AutomaticTax.Enabled)
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) subscription to automatic tax while processing event with ID {EventID}",
provider.Id,
eventId);
}
}
}
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice,
Subscription subscription, Guid providerId)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
@@ -195,96 +525,114 @@ public class UpcomingInvoiceHandler(
}
}
private async Task AlignOrganizationTaxConcernsAsync(
#endregion
#region Shared
private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var items = invoice.Lines.Select(i => i.Description).ToList();
if (invoice is { NextPaymentAttempt: not null, AmountDue: > 0 })
{
await mailService.SendInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
items,
true);
}
}
private async Task SendFamiliesRenewalEmailAsync(
Organization organization,
Subscription subscription,
Customer customer,
string eventId)
Plan familiesPlan,
Plan planBeforeAlignment)
{
var nonUSBusinessUse =
organization.PlanType.GetProductTier() != ProductTierType.Families &&
customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
if (nonUSBusinessUse && customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
await (planBeforeAlignment switch
{
try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) to reverse tax exemption while processing event with ID {EventID}",
organization.Id,
eventId);
}
}
if (!subscription.AutomaticTax.Enabled)
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
{ Type: PlanType.FamiliesAnnually2025 } => SendFamilies2020RenewalEmailAsync(organization, familiesPlan),
{ Type: PlanType.FamiliesAnnually2019 } => SendFamilies2019RenewalEmailAsync(organization, familiesPlan),
_ => throw new InvalidOperationException("Unsupported families plan in SendFamiliesRenewalEmailAsync().")
});
}
catch (Exception exception)
private async Task SendFamilies2020RenewalEmailAsync(Organization organization, Plan familiesPlan)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) subscription to automatic tax while processing event with ID {EventID}",
organization.Id,
eventId);
}
var email = new Families2020RenewalMail
{
ToEmails = [organization.BillingEmail],
View = new Families2020RenewalMailView
{
MonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))
}
};
await mailer.SendEmail(email);
}
private async Task AlignProviderTaxConcernsAsync(
Provider provider,
Subscription subscription,
Customer customer,
string eventId)
private async Task SendFamilies2019RenewalEmailAsync(Organization organization, Plan familiesPlan)
{
if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
if (coupon == null)
{
try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) to reverse tax exemption while processing event with ID {EventID}",
provider.Id,
eventId);
}
throw new InvalidOperationException($"Coupon for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} not found");
}
if (!subscription.AutomaticTax.Enabled)
if (coupon.PercentOff == null)
{
try
throw new InvalidOperationException($"coupon.PercentOff for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} is null");
}
var discountedAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice * (100 - coupon.PercentOff.Value) / 100;
var email = new Families2019RenewalMail
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
ToEmails = [organization.BillingEmail],
View = new Families2019RenewalMailView
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
BaseMonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")),
BaseAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")),
DiscountAmount = $"{coupon.PercentOff}%",
DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US"))
}
catch (Exception exception)
};
await mailer.SendEmail(email);
}
private async Task SendPremiumRenewalEmailAsync(
User user,
PremiumPlan premiumPlan)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) subscription to automatic tax while processing event with ID {EventID}",
provider.Id,
eventId);
var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount);
if (coupon == null)
{
throw new InvalidOperationException($"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found");
}
if (coupon.PercentOff == null)
{
throw new InvalidOperationException($"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null");
}
var discountedAnnualRenewalPrice = premiumPlan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
var email = new PremiumRenewalMail
{
ToEmails = [user.Email],
View = new PremiumRenewalMailView
{
BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")),
DiscountAmount = $"{coupon.PercentOff}%",
DiscountedMonthlyRenewalPrice = (discountedAnnualRenewalPrice / 12).ToString("C", new CultureInfo("en-US"))
}
};
await mailer.SendEmail(email);
}
#endregion
}

View File

@@ -10,7 +10,6 @@ using Bit.Core.Billing.Extensions;
using Bit.Core.Context;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.SecretsManager.Repositories.Noop;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -129,12 +128,8 @@ public class Startup
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings)
IWebHostEnvironment env)
{
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

View File

@@ -36,5 +36,6 @@
"onyx": {
"personaId": 68
}
}
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}

View File

@@ -30,9 +30,6 @@
"connectionString": "SECRET",
"applicationCacheTopicName": "SECRET"
},
"sentry": {
"dsn": "SECRET"
},
"notificationHub": {
"connectionString": "SECRET",
"hubName": "SECRET"

View File

@@ -138,6 +138,9 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
/// If set to true, disables Secrets Manager ads for users in the organization
/// </summary>
public bool UseDisableSmAdsForUsers { get; set; }
/// If set to true, the organization has phishing protection enabled.
/// </summary>
public bool UsePhishingBlocker { get; set; }
public void SetNewId()
{
@@ -340,5 +343,6 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
UseDisableSmAdsForUsers = license.UseDisableSmAdsForUsers;
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation;
UsePhishingBlocker = license.UsePhishingBlocker;
}
}

View File

@@ -60,6 +60,7 @@ public enum EventType : int
OrganizationUser_RejectedAuthRequest = 1514,
OrganizationUser_Deleted = 1515, // Both user and organization user data were deleted
OrganizationUser_Left = 1516, // User voluntarily left the organization
OrganizationUser_AutomaticallyConfirmed = 1517,
Organization_Updated = 1600,
Organization_PurgedVault = 1601,

View File

@@ -21,6 +21,7 @@ public enum PolicyType : byte
UriMatchDefaults = 16,
AutotypeDefaultSetting = 17,
AutomaticUserConfirmation = 18,
BlockClaimedDomainAccountCreation = 19,
}
public static class PolicyTypeExtensions
@@ -52,6 +53,7 @@ public static class PolicyTypeExtensions
PolicyType.UriMatchDefaults => "URI match defaults",
PolicyType.AutotypeDefaultSetting => "Autotype default setting",
PolicyType.AutomaticUserConfirmation => "Automatically confirm invited users",
PolicyType.BlockClaimedDomainAccountCreation => "Block account creation for claimed domains",
};
}
}

View File

@@ -1,8 +1,8 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
@@ -36,13 +36,18 @@ public class IntegrationTemplateContext(EventMessage eventMessage)
public string DateIso8601 => Date.ToString("o");
public string EventMessage => JsonSerializer.Serialize(Event);
public User? User { get; set; }
public OrganizationUserUserDetails? User { get; set; }
public string? UserName => User?.Name;
public string? UserEmail => User?.Email;
public OrganizationUserType? UserType => User?.Type;
public User? ActingUser { get; set; }
public OrganizationUserUserDetails? ActingUser { get; set; }
public string? ActingUserName => ActingUser?.Name;
public string? ActingUserEmail => ActingUser?.Email;
public OrganizationUserType? ActingUserType => ActingUser?.Type;
public Group? Group { get; set; }
public string? GroupName => Group?.Name;
public Organization? Organization { get; set; }
public string? OrganizationName => Organization?.DisplayName();

View File

@@ -53,4 +53,5 @@ public interface IProfileOrganizationDetails
bool UseAdminSponsoredFamilies { get; set; }
bool UseOrganizationDomains { get; set; }
bool UseAutomaticUserConfirmation { get; set; }
bool UsePhishingBlocker { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
public record AcceptedOrganizationUserToConfirm
{
public required Guid OrganizationUserId { get; init; }
public required Guid UserId { get; init; }
public required string Key { get; init; }
}

View File

@@ -30,6 +30,7 @@ public class OrganizationAbility
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers;
UsePhishingBlocker = organization.UsePhishingBlocker;
}
public Guid Id { get; set; }
@@ -53,4 +54,5 @@ public class OrganizationAbility
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UseDisableSmAdsForUsers { get; set; }
public bool UsePhishingBlocker { get; set; }
}

View File

@@ -65,4 +65,5 @@ public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails
public bool UseAdminSponsoredFamilies { get; set; }
public bool? IsAdminInitiated { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UsePhishingBlocker { get; set; }
}

View File

@@ -155,6 +155,7 @@ public class SelfHostedOrganizationDetails : Organization
UseRiskInsights = UseRiskInsights,
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
UseDisableSmAdsForUsers = UseDisableSmAdsForUsers,
UsePhishingBlocker = UsePhishingBlocker,
};
}
}

View File

@@ -56,4 +56,5 @@ public class ProviderUserOrganizationDetails : IProfileOrganizationDetails
public string? SsoExternalId { get; set; }
public string? Permissions { get; set; }
public string? ResetPasswordKey { get; set; }
public bool UsePhishingBlocker { get; set; }
}

View File

@@ -33,6 +33,12 @@ public class SlackOAuthResponse : SlackApiResponse
public SlackTeam Team { get; set; } = new();
}
public class SlackSendMessageResponse : SlackApiResponse
{
[JsonPropertyName("channel")]
public string Channel { get; set; } = string.Empty;
}
public class SlackTeam
{
public string Id { get; set; } = string.Empty;

View File

@@ -0,0 +1,186 @@
using Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using OneOf.Types;
using CommandResult = Bit.Core.AdminConsole.Utilities.v2.Results.CommandResult;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IAutomaticallyConfirmOrganizationUsersValidator validator,
IEventService eventService,
IMailService mailService,
IUserRepository userRepository,
IPushRegistrationService pushRegistrationService,
IDeviceRepository deviceRepository,
IPushNotificationService pushNotificationService,
IPolicyRequirementQuery policyRequirementQuery,
ICollectionRepository collectionRepository,
TimeProvider timeProvider,
ILogger<AutomaticallyConfirmOrganizationUserCommand> logger) : IAutomaticallyConfirmOrganizationUserCommand
{
public async Task<CommandResult> AutomaticallyConfirmOrganizationUserAsync(AutomaticallyConfirmOrganizationUserRequest request)
{
var validatorRequest = await RetrieveDataAsync(request);
var validatedData = await validator.ValidateAsync(validatorRequest);
return await validatedData.Match<Task<CommandResult>>(
error => Task.FromResult(new CommandResult(error)),
async _ =>
{
var userToConfirm = new AcceptedOrganizationUserToConfirm
{
OrganizationUserId = validatedData.Request.OrganizationUser!.Id,
UserId = validatedData.Request.OrganizationUser.UserId!.Value,
Key = validatedData.Request.Key
};
// This operation is idempotent. If false, the user is already confirmed and no additional side effects are required.
if (!await organizationUserRepository.ConfirmOrganizationUserAsync(userToConfirm))
{
return new None();
}
await CreateDefaultCollectionsAsync(validatedData.Request);
await Task.WhenAll(
LogOrganizationUserConfirmedEventAsync(validatedData.Request),
SendConfirmedOrganizationUserEmailAsync(validatedData.Request),
SyncOrganizationKeysAsync(validatedData.Request)
);
return new None();
}
);
}
private async Task SyncOrganizationKeysAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
await DeleteDeviceRegistrationAsync(request);
await PushSyncOrganizationKeysAsync(request);
}
private async Task CreateDefaultCollectionsAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
try
{
if (!await ShouldCreateDefaultCollectionAsync(request))
{
return;
}
await collectionRepository.CreateAsync(
new Collection
{
OrganizationId = request.Organization!.Id,
Name = request.DefaultUserCollectionName,
Type = CollectionType.DefaultUserCollection
},
groups: null,
[new CollectionAccessSelection
{
Id = request.OrganizationUser!.Id,
Manage = true
}]);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create default collection for user.");
}
}
/// <summary>
/// Determines whether a default collection should be created for an organization user during the confirmation process.
/// </summary>
/// <param name="request">
/// The validation request containing information about the user, organization, and collection settings.
/// </param>
/// <returns>The result is a boolean value indicating whether a default collection should be created.</returns>
private async Task<bool> ShouldCreateDefaultCollectionAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) =>
!string.IsNullOrWhiteSpace(request.DefaultUserCollectionName)
&& (await policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(request.OrganizationUser!.UserId!.Value))
.RequiresDefaultCollectionOnConfirm(request.Organization!.Id);
private async Task PushSyncOrganizationKeysAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
try
{
await pushNotificationService.PushSyncOrgKeysAsync(request.OrganizationUser!.UserId!.Value);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to push organization keys.");
}
}
private async Task LogOrganizationUserConfirmedEventAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
try
{
await eventService.LogOrganizationUserEventAsync(request.OrganizationUser,
EventType.OrganizationUser_AutomaticallyConfirmed,
timeProvider.GetUtcNow().UtcDateTime);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to log OrganizationUser_AutomaticallyConfirmed event.");
}
}
private async Task SendConfirmedOrganizationUserEmailAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
try
{
var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value);
await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name,
user!.Email,
request.OrganizationUser.AccessSecretsManager);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send OrganizationUserConfirmed.");
}
}
private async Task DeleteDeviceRegistrationAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
try
{
var devices = (await deviceRepository.GetManyByUserIdAsync(request.OrganizationUser!.UserId!.Value))
.Where(d => !string.IsNullOrWhiteSpace(d.PushToken))
.Select(d => d.Id.ToString());
await pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, request.Organization!.Id.ToString());
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete device registration.");
}
}
private async Task<AutomaticallyConfirmOrganizationUserValidationRequest> RetrieveDataAsync(
AutomaticallyConfirmOrganizationUserRequest request)
{
return new AutomaticallyConfirmOrganizationUserValidationRequest
{
OrganizationUserId = request.OrganizationUserId,
OrganizationId = request.OrganizationId,
Key = request.Key,
DefaultUserCollectionName = request.DefaultUserCollectionName,
PerformedBy = request.PerformedBy,
OrganizationUser = await organizationUserRepository.GetByIdAsync(request.OrganizationUserId),
Organization = await organizationRepository.GetByIdAsync(request.OrganizationId)
};
}
}

View File

@@ -0,0 +1,29 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
/// <summary>
/// Automatically Confirm User Command Request
/// </summary>
public record AutomaticallyConfirmOrganizationUserRequest
{
public required Guid OrganizationUserId { get; init; }
public required Guid OrganizationId { get; init; }
public required string Key { get; init; }
public required string DefaultUserCollectionName { get; init; }
public required IActingUser PerformedBy { get; init; }
}
/// <summary>
/// Automatically Confirm User Validation Request
/// </summary>
/// <remarks>
/// This is used to hold retrieved data and pass it to the validator
/// </remarks>
public record AutomaticallyConfirmOrganizationUserValidationRequest : AutomaticallyConfirmOrganizationUserRequest
{
public OrganizationUser? OrganizationUser { get; set; }
public Organization? Organization { get; set; }
}

View File

@@ -0,0 +1,116 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
public class AutomaticallyConfirmOrganizationUsersValidator(
IOrganizationUserRepository organizationUserRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IPolicyRequirementQuery policyRequirementQuery,
IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator
{
public async Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync(
AutomaticallyConfirmOrganizationUserValidationRequest request)
{
// User must exist
if (request is { OrganizationUser: null } || request.OrganizationUser is { UserId: null })
{
return Invalid(request, new UserNotFoundError());
}
// Organization must exist
if (request is { Organization: null })
{
return Invalid(request, new OrganizationNotFound());
}
// User must belong to the organization
if (request.OrganizationUser.OrganizationId != request.Organization.Id)
{
return Invalid(request, new OrganizationUserIdIsInvalid());
}
// User must be accepted
if (request is { OrganizationUser.Status: not OrganizationUserStatusType.Accepted })
{
return Invalid(request, new UserIsNotAccepted());
}
// User must be of type User
if (request is { OrganizationUser.Type: not OrganizationUserType.User })
{
return Invalid(request, new UserIsNotUserType());
}
if (!await OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(request))
{
return Invalid(request, new AutomaticallyConfirmUsersPolicyIsNotEnabled());
}
if (!await OrganizationUserConformsToTwoFactorRequiredPolicyAsync(request))
{
return Invalid(request, new UserDoesNotHaveTwoFactorEnabled());
}
if (await OrganizationUserConformsToSingleOrgPolicyAsync(request) is { } error)
{
return Invalid(request, error);
}
return Valid(request);
}
private async Task<bool> OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(
AutomaticallyConfirmOrganizationUserValidationRequest request) =>
await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId,
PolicyType.AutomaticUserConfirmation) is { Enabled: true }
&& request.Organization is { UseAutomaticUserConfirmation: true };
private async Task<bool> OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
if ((await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync([request.OrganizationUser!.UserId!.Value]))
.Any(x => x.userId == request.OrganizationUser.UserId && x.twoFactorIsEnabled))
{
return true;
}
return !(await policyRequirementQuery.GetAsync<RequireTwoFactorPolicyRequirement>(request.OrganizationUser.UserId!.Value))
.IsTwoFactorRequiredForOrganization(request.Organization!.Id);
}
private async Task<Error?> OrganizationUserConformsToSingleOrgPolicyAsync(
AutomaticallyConfirmOrganizationUserValidationRequest request)
{
var allOrganizationUsersForUser = await organizationUserRepository
.GetManyByUserAsync(request.OrganizationUser!.UserId!.Value);
if (allOrganizationUsersForUser.Count == 1)
{
return null;
}
var policyRequirement = await policyRequirementQuery
.GetAsync<SingleOrganizationPolicyRequirement>(request.OrganizationUser!.UserId!.Value);
if (policyRequirement.IsSingleOrgEnabledForThisOrganization(request.Organization!.Id))
{
return new OrganizationEnforcesSingleOrgPolicy();
}
if (policyRequirement.IsSingleOrgEnabledForOrganizationsOtherThan(request.Organization.Id))
{
return new OtherOrganizationEnforcesSingleOrgPolicy();
}
return null;
}
}

View File

@@ -0,0 +1,13 @@
using Bit.Core.AdminConsole.Utilities.v2;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
public record OrganizationNotFound() : NotFoundError("Invalid organization");
public record FailedToWriteToEventLog() : InternalError("Failed to write to event log");
public record UserIsNotUserType() : BadRequestError("Only organization users with the User role can be automatically confirmed");
public record UserIsNotAccepted() : BadRequestError("Cannot confirm user that has not accepted the invitation.");
public record OrganizationUserIdIsInvalid() : BadRequestError("Invalid organization user id.");
public record UserDoesNotHaveTwoFactorEnabled() : BadRequestError("User does not have two-step login enabled.");
public record OrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization until they leave or remove all other organizations");
public record OtherOrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization because they are in another organization which forbids it.");
public record AutomaticallyConfirmUsersPolicyIsNotEnabled() : BadRequestError("Cannot confirm this member because the Automatically Confirm Users policy is not enabled.");

View File

@@ -0,0 +1,9 @@
using Bit.Core.AdminConsole.Utilities.v2.Validation;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
public interface IAutomaticallyConfirmOrganizationUsersValidator
{
Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync(
AutomaticallyConfirmOrganizationUserValidationRequest request);
}

View File

@@ -0,0 +1,22 @@
# Automatic User Confirmation
Owned by: admin-console
Automatic confirmation requests are server driven events that are sent to the admin's client where via a background service the confirmation will occur. The basic model
for the workflow is as follows:
- The Api server sends an invite email to a user.
- The user accepts the invite request, which is sent back to the Api server
- The Api server sends a push-notification with the OrganizationId and UserId to a client admin session.
- The Client performs the key exchange in the background and POSTs the ConfirmRequest back to the Api server
- The Api server runs the OrgUser_Confirm sproc to confirm the user in the DB
This Feature has the following security measures in place in order to achieve our security goals:
- The single organization exemption for admins/owners is removed for this policy.
- This is enforced by preventing enabling the policy and organization plan feature if there are non-compliant users
- Emergency access is removed for all organization users
- Automatic confirmation will only apply to the User role (You cannot auto confirm admins/owners to an organization)
- The organization has no members with the Provider user type.
- This will also prevent the policy and organization plan feature from being enabled
- This will prevent sending organization invites to provider users

View File

@@ -1,4 +1,6 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;

View File

@@ -1,8 +1,9 @@
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount.ValidationResultHelpers;
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;

View File

@@ -1,15 +1,6 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.Utilities.v2;
/// <summary>
/// A strongly typed error containing a reason that an action failed.
/// This is used for business logic validation and other expected errors, not exceptions.
/// </summary>
public abstract record Error(string Message);
/// <summary>
/// An <see cref="Error"/> type that maps to a NotFoundResult at the api layer.
/// </summary>
/// <param name="Message"></param>
public abstract record NotFoundError(string Message) : Error(Message);
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
public record UserNotFoundError() : NotFoundError("Invalid user.");
public record UserNotClaimedError() : Error("Member is not claimed by the organization.");

View File

@@ -1,4 +1,6 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.Utilities.v2.Results;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
public interface IDeleteClaimedOrganizationUserAccountCommand
{

View File

@@ -1,4 +1,6 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
public interface IDeleteClaimedOrganizationUserAccountValidator
{

Some files were not shown because too many files have changed in this diff Show More