mirror of
https://github.com/bitwarden/server
synced 2025-12-10 13:23:27 +00:00
Merge branch 'main' into arch/seeder-api
This commit is contained in:
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
@@ -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:
|
||||
8
.github/renovate.json5
vendored
8
.github/renovate.json5
vendored
@@ -41,6 +41,10 @@
|
||||
matchUpdateTypes: ["patch"],
|
||||
dependencyDashboardApproval: false,
|
||||
},
|
||||
{
|
||||
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
|
||||
groupName: "sdk-internal",
|
||||
},
|
||||
{
|
||||
matchManagers: ["dockerfile", "docker-compose"],
|
||||
commitMessagePrefix: "[deps] BRE:",
|
||||
@@ -86,11 +90,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",
|
||||
|
||||
35
.github/workflows/build.yml
vendored
35
.github/workflows/build.yml
vendored
@@ -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
|
||||
@@ -279,7 +280,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 +291,7 @@ jobs:
|
||||
|
||||
upload:
|
||||
name: Upload
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: build-artifacts
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -364,7 +365,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 +375,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 +387,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 +409,7 @@ jobs:
|
||||
|
||||
build-mssqlmigratorutility:
|
||||
name: Build MSSQL migrator utility
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- lint
|
||||
defaults:
|
||||
@@ -446,7 +447,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 +455,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 +466,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:
|
||||
@@ -488,7 +489,7 @@ jobs:
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Trigger self-host build
|
||||
- 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 }}
|
||||
@@ -496,7 +497,7 @@ jobs:
|
||||
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
|
||||
|
||||
1
.github/workflows/review-code.yml
vendored
1
.github/workflows/review-code.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/test-database.yml
vendored
8
.github/workflows/test-database.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.11.0</Version>
|
||||
<Version>2025.11.1</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
},
|
||||
"storage": {
|
||||
"connectionString": "UseDevelopmentStorage=true"
|
||||
}
|
||||
},
|
||||
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,6 @@
|
||||
"connectionString": "SECRET",
|
||||
"applicationCacheTopicName": "SECRET"
|
||||
},
|
||||
"sentry": {
|
||||
"dsn": "SECRET"
|
||||
},
|
||||
"notificationHub": {
|
||||
"connectionString": "SECRET",
|
||||
"hubName": "SECRET"
|
||||
|
||||
@@ -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.
|
||||
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email);
|
||||
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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -100,8 +100,6 @@ public class Startup
|
||||
IdentityModelEventSource.ShowPII = true;
|
||||
}
|
||||
|
||||
app.UseSerilog(env, appLifetime, globalSettings);
|
||||
|
||||
// Add general security headers
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"storage": {
|
||||
"connectionString": "UseDevelopmentStorage=true"
|
||||
},
|
||||
"developmentDirectory": "../../../dev"
|
||||
"developmentDirectory": "../../../dev",
|
||||
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"storage": {
|
||||
"connectionString": "UseDevelopmentStorage=true"
|
||||
}
|
||||
},
|
||||
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -738,6 +744,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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Context;
|
||||
@@ -41,8 +42,9 @@ public class PoliciesController : Controller
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IUserService _userService;
|
||||
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ISavePolicyCommand _savePolicyCommand;
|
||||
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
|
||||
|
||||
public PoliciesController(IPolicyRepository policyRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@@ -53,7 +55,9 @@ public class PoliciesController : Controller
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
|
||||
IOrganizationRepository organizationRepository,
|
||||
ISavePolicyCommand savePolicyCommand)
|
||||
IFeatureService featureService,
|
||||
ISavePolicyCommand savePolicyCommand,
|
||||
IVNextSavePolicyCommand vNextSavePolicyCommand)
|
||||
{
|
||||
_policyRepository = policyRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@@ -65,7 +69,9 @@ public class PoliciesController : Controller
|
||||
_organizationRepository = organizationRepository;
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||
_featureService = featureService;
|
||||
_savePolicyCommand = savePolicyCommand;
|
||||
_vNextSavePolicyCommand = vNextSavePolicyCommand;
|
||||
}
|
||||
|
||||
[HttpGet("{type}")]
|
||||
@@ -203,27 +209,22 @@ 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 = await _savePolicyCommand.VNextSaveAsync(savePolicyRequest);
|
||||
var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ?
|
||||
await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) :
|
||||
await _savePolicyCommand.VNextSaveAsync(savePolicyRequest);
|
||||
|
||||
return new PolicyResponseModel(policy);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,15 @@ using System.Net;
|
||||
using Bit.Api.AdminConsole.Public.Models.Request;
|
||||
using Bit.Api.AdminConsole.Public.Models.Response;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -22,18 +26,24 @@ public class PoliciesController : Controller
|
||||
private readonly IPolicyRepository _policyRepository;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ISavePolicyCommand _savePolicyCommand;
|
||||
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
|
||||
|
||||
public PoliciesController(
|
||||
IPolicyRepository policyRepository,
|
||||
IPolicyService policyService,
|
||||
ICurrentContext currentContext,
|
||||
ISavePolicyCommand savePolicyCommand)
|
||||
IFeatureService featureService,
|
||||
ISavePolicyCommand savePolicyCommand,
|
||||
IVNextSavePolicyCommand vNextSavePolicyCommand)
|
||||
{
|
||||
_policyRepository = policyRepository;
|
||||
_policyService = policyService;
|
||||
_currentContext = currentContext;
|
||||
_featureService = featureService;
|
||||
_savePolicyCommand = savePolicyCommand;
|
||||
_vNextSavePolicyCommand = vNextSavePolicyCommand;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -87,8 +97,17 @@ public class PoliciesController : Controller
|
||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||
public async Task<IActionResult> Put(PolicyType type, [FromBody] PolicyUpdateRequestModel model)
|
||||
{
|
||||
var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type);
|
||||
var policy = await _savePolicyCommand.SaveAsync(policyUpdate);
|
||||
Policy policy;
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor))
|
||||
{
|
||||
var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type);
|
||||
policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel);
|
||||
}
|
||||
else
|
||||
{
|
||||
var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type);
|
||||
policy = await _savePolicyCommand.SaveAsync(policyUpdate);
|
||||
}
|
||||
|
||||
var response = new PolicyResponseModel(policy);
|
||||
return new JsonResult(response);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -8,6 +8,8 @@ namespace Bit.Api.AdminConsole.Public.Models.Request;
|
||||
|
||||
public class PolicyUpdateRequestModel : PolicyBaseModel
|
||||
{
|
||||
public Dictionary<string, object>? Metadata { get; set; }
|
||||
|
||||
public PolicyUpdate ToPolicyUpdate(Guid organizationId, PolicyType type)
|
||||
{
|
||||
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type);
|
||||
@@ -21,4 +23,22 @@ public class PolicyUpdateRequestModel : PolicyBaseModel
|
||||
PerformedBy = new SystemUser(EventSystemUser.PublicApi)
|
||||
};
|
||||
}
|
||||
|
||||
public SavePolicyModel ToSavePolicyModel(Guid organizationId, PolicyType type)
|
||||
{
|
||||
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type);
|
||||
|
||||
var policyUpdate = new PolicyUpdate
|
||||
{
|
||||
Type = type,
|
||||
OrganizationId = organizationId,
|
||||
Data = serializedData,
|
||||
Enabled = Enabled.GetValueOrDefault()
|
||||
};
|
||||
|
||||
var performedBy = new SystemUser(EventSystemUser.PublicApi);
|
||||
var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, type);
|
||||
|
||||
return new SavePolicyModel(policyUpdate, performedBy, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,16 +86,24 @@ 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)
|
||||
{
|
||||
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
|
||||
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
|
||||
return new SubscriptionResponseModel(user, subscriptionInfo, license);
|
||||
}
|
||||
else if (!globalSettings.SelfHosted)
|
||||
{
|
||||
var license = await userService.GenerateLicenseAsync(user);
|
||||
return new SubscriptionResponseModel(user, license);
|
||||
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, includeMilestone2Discount);
|
||||
}
|
||||
else
|
||||
{
|
||||
var license = await userService.GenerateLicenseAsync(user);
|
||||
return new SubscriptionResponseModel(user, license);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1420,11 +1422,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 +1523,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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,6 @@
|
||||
"send": {
|
||||
"connectionString": "SECRET"
|
||||
},
|
||||
"sentry": {
|
||||
"dsn": "SECRET"
|
||||
},
|
||||
"notificationHub": {
|
||||
"connectionString": "SECRET",
|
||||
"hubName": "SECRET"
|
||||
|
||||
@@ -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;
|
||||
|
||||
36
src/Billing/Controllers/JobsController.cs
Normal file
36
src/Billing/Controllers/JobsController.cs
Normal 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}" });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 class JobsHostedService(
|
||||
GlobalSettings globalSettings,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<JobsHostedService> logger,
|
||||
ILogger<JobListener> listenerLogger,
|
||||
ISchedulerFactory schedulerFactory)
|
||||
: BaseJobsHostedService(globalSettings, serviceProvider, logger, listenerLogger)
|
||||
{
|
||||
public JobsHostedService(
|
||||
GlobalSettings globalSettings,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<JobsHostedService> logger,
|
||||
ILogger<JobListener> listenerLogger)
|
||||
: base(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);
|
||||
}
|
||||
}
|
||||
|
||||
207
src/Billing/Jobs/ReconcileAdditionalStorageJob.cs
Normal file
207
src/Billing/Jobs/ReconcileAdditionalStorageJob.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -98,6 +98,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,
|
||||
|
||||
@@ -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,21 @@ 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.Families2020Renewal;
|
||||
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 +34,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 +44,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,116 +58,436 @@ public class UpcomingInvoiceHandler(
|
||||
|
||||
if (organizationId.HasValue)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id);
|
||||
|
||||
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
|
||||
if (!plan.IsAnnual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
|
||||
{
|
||||
var sponsorshipIsValid = await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
|
||||
|
||||
if (!sponsorshipIsValid)
|
||||
{
|
||||
/*
|
||||
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
|
||||
* price. Given that this is the case, we need the new invoice amount
|
||||
*/
|
||||
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
|
||||
}
|
||||
}
|
||||
|
||||
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 HandleOrganizationUpcomingInvoiceAsync(
|
||||
organizationId.Value,
|
||||
parsedEvent,
|
||||
invoice,
|
||||
customer,
|
||||
subscription);
|
||||
}
|
||||
else if (userId.HasValue)
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(userId.Value);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation())
|
||||
{
|
||||
try
|
||||
{
|
||||
await stripeFacade.UpdateSubscription(subscription.Id,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
|
||||
user.Id,
|
||||
parsedEvent.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (user.Premium)
|
||||
{
|
||||
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
|
||||
}
|
||||
await HandlePremiumUsersUpcomingInvoiceAsync(
|
||||
userId.Value,
|
||||
parsedEvent,
|
||||
invoice,
|
||||
customer,
|
||||
subscription);
|
||||
}
|
||||
else if (providerId.HasValue)
|
||||
{
|
||||
var provider = await providerRepository.GetByIdAsync(providerId.Value);
|
||||
await HandleProviderUpcomingInvoiceAsync(
|
||||
providerId.Value,
|
||||
parsedEvent,
|
||||
invoice,
|
||||
customer,
|
||||
subscription);
|
||||
}
|
||||
}
|
||||
|
||||
if (provider == null)
|
||||
#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;
|
||||
}
|
||||
|
||||
// Don't send the upcoming invoice email unless the organization's on an annual plan.
|
||||
if (!plan.IsAnnual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
|
||||
{
|
||||
var sponsorshipIsValid =
|
||||
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId);
|
||||
|
||||
if (!sponsorshipIsValid)
|
||||
{
|
||||
/*
|
||||
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
|
||||
* price. Given that this is the case, we need the new invoice amount
|
||||
*/
|
||||
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
|
||||
}
|
||||
}
|
||||
|
||||
await SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice);
|
||||
}
|
||||
|
||||
private async Task AlignOrganizationTaxConcernsAsync(
|
||||
Organization organization,
|
||||
Subscription subscription,
|
||||
Customer customer,
|
||||
string eventId)
|
||||
{
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id);
|
||||
|
||||
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
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.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
|
||||
if (user.Premium)
|
||||
{
|
||||
await mailService.SendInvoiceUpcoming(
|
||||
validEmails,
|
||||
invoice.AmountDue / 100M,
|
||||
invoice.NextPaymentAttempt.Value,
|
||||
items,
|
||||
true);
|
||||
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice, Subscription subscription, Guid providerId)
|
||||
private async Task AlignPremiumUsersTaxConcernsAsync(
|
||||
User user,
|
||||
Event @event,
|
||||
Customer customer,
|
||||
Subscription subscription)
|
||||
{
|
||||
if (!subscription.AutomaticTax.Enabled && customer.HasRecognizedTaxLocation())
|
||||
{
|
||||
try
|
||||
{
|
||||
await stripeFacade.UpdateSubscription(subscription.Id,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
|
||||
user.Id,
|
||||
@event.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> AlignPremiumUsersSubscriptionConcernsAsync(
|
||||
User user,
|
||||
Event @event,
|
||||
Subscription subscription)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Providers
|
||||
|
||||
private async Task HandleProviderUpcomingInvoiceAsync(
|
||||
Guid providerId,
|
||||
Event @event,
|
||||
Invoice invoice,
|
||||
Customer customer,
|
||||
Subscription subscription)
|
||||
{
|
||||
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, @event.Id);
|
||||
|
||||
if (!string.IsNullOrEmpty(provider.BillingEmail))
|
||||
{
|
||||
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AlignProviderTaxConcernsAsync(
|
||||
Provider provider,
|
||||
Subscription subscription,
|
||||
Customer customer,
|
||||
string eventId)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 +523,60 @@ 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)
|
||||
{
|
||||
var nonUSBusinessUse =
|
||||
organization.PlanType.GetProductTier() != ProductTierType.Families &&
|
||||
customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
|
||||
|
||||
if (nonUSBusinessUse && customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
|
||||
var email = new Families2020RenewalMail
|
||||
{
|
||||
try
|
||||
ToEmails = [organization.BillingEmail],
|
||||
View = new Families2020RenewalMailView
|
||||
{
|
||||
await stripeFacade.UpdateCustomer(subscription.CustomerId,
|
||||
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
|
||||
MonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
await mailer.SendEmail(email);
|
||||
}
|
||||
|
||||
private async Task AlignProviderTaxConcernsAsync(
|
||||
Provider provider,
|
||||
Subscription subscription,
|
||||
Customer customer,
|
||||
string eventId)
|
||||
private async Task SendPremiumRenewalEmailAsync(
|
||||
User user,
|
||||
PremiumPlan premiumPlan)
|
||||
{
|
||||
if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
|
||||
customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
|
||||
/* TODO: Replace with proper premium renewal email template once finalized.
|
||||
Using Families2020RenewalMail as a temporary stop-gap. */
|
||||
var email = new Families2020RenewalMail
|
||||
{
|
||||
try
|
||||
ToEmails = [user.Email],
|
||||
View = new Families2020RenewalMailView
|
||||
{
|
||||
await stripeFacade.UpdateCustomer(subscription.CustomerId,
|
||||
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
|
||||
MonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US"))
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
await mailer.SendEmail(email);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"billingSettings": {
|
||||
"onyx": {
|
||||
"personaId": 68
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||
}
|
||||
|
||||
@@ -30,9 +30,6 @@
|
||||
"connectionString": "SECRET",
|
||||
"applicationCacheTopicName": "SECRET"
|
||||
},
|
||||
"sentry": {
|
||||
"dsn": "SECRET"
|
||||
},
|
||||
"notificationHub": {
|
||||
"connectionString": "SECRET",
|
||||
"hubName": "SECRET"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@@ -24,7 +25,9 @@ public class VerifyOrganizationDomainCommand(
|
||||
IEventService eventService,
|
||||
IGlobalSettings globalSettings,
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
ISavePolicyCommand savePolicyCommand,
|
||||
IVNextSavePolicyCommand vNextSavePolicyCommand,
|
||||
IMailService mailService,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
@@ -131,15 +134,26 @@ public class VerifyOrganizationDomainCommand(
|
||||
await SendVerifiedDomainUserEmailAsync(domain);
|
||||
}
|
||||
|
||||
private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser) =>
|
||||
await savePolicyCommand.SaveAsync(
|
||||
new PolicyUpdate
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Type = PolicyType.SingleOrg,
|
||||
Enabled = true,
|
||||
PerformedBy = actingUser
|
||||
});
|
||||
private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser)
|
||||
{
|
||||
var policyUpdate = new PolicyUpdate
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
Type = PolicyType.SingleOrg,
|
||||
Enabled = true,
|
||||
PerformedBy = actingUser
|
||||
};
|
||||
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor))
|
||||
{
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser);
|
||||
await vNextSavePolicyCommand.SaveAsync(savePolicyModel);
|
||||
}
|
||||
else
|
||||
{
|
||||
await savePolicyCommand.SaveAsync(policyUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendVerifiedDomainUserEmailAsync(OrganizationDomain domain)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||
using Bit.Core.AdminConsole.Utilities.v2.Results;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Command to automatically confirm an organization user.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The auto-confirm feature enables eligible client apps to confirm OrganizationUsers
|
||||
/// automatically via push notifications, eliminating the need for manual administrator
|
||||
/// intervention. Client apps receive a push notification, perform the required key exchange,
|
||||
/// and submit an auto-confirm request to the server. This command processes those
|
||||
/// client-initiated requests and should only be used in that specific context.
|
||||
/// </remarks>
|
||||
public interface IAutomaticallyConfirmOrganizationUserCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatically confirms the organization user based on the provided request data.
|
||||
/// </summary>
|
||||
/// <param name="request">The request containing necessary information to confirm the organization user.</param>
|
||||
/// <remarks>
|
||||
/// This action has side effects. The side effects are
|
||||
/// <ul>
|
||||
/// <li>Creating an event log entry.</li>
|
||||
/// <li>Syncing organization keys with the user.</li>
|
||||
/// <li>Deleting any registered user devices for the organization.</li>
|
||||
/// <li>Sending an email to the confirmed user.</li>
|
||||
/// <li>Creating the default collection if applicable.</li>
|
||||
/// </ul>
|
||||
///
|
||||
/// Each of these actions is performed independently of each other and not guaranteed to be performed in any order.
|
||||
/// Errors will be reported back for the actions that failed in a consolidated error message.
|
||||
/// </remarks>
|
||||
/// <returns>
|
||||
/// The result of the command. If there was an error, the result will contain a typed error describing the problem
|
||||
/// that occurred.
|
||||
/// </returns>
|
||||
Task<CommandResult> AutomaticallyConfirmOrganizationUserAsync(AutomaticallyConfirmOrganizationUserRequest request);
|
||||
}
|
||||
@@ -75,8 +75,7 @@ public class CloudOrganizationSignUpCommand(
|
||||
PlanType = plan!.Type,
|
||||
Seats = (short)(plan.PasswordManager.BaseSeats + signup.AdditionalSeats),
|
||||
MaxCollections = plan.PasswordManager.MaxCollections,
|
||||
MaxStorageGb = !plan.PasswordManager.BaseStorageGb.HasValue ?
|
||||
(short?)null : (short)(plan.PasswordManager.BaseStorageGb.Value + signup.AdditionalStorageGb),
|
||||
MaxStorageGb = (short)(plan.PasswordManager.BaseStorageGb + signup.AdditionalStorageGb),
|
||||
UsePolicies = plan.HasPolicies,
|
||||
UseSso = plan.HasSso,
|
||||
UseGroups = plan.HasGroups,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
|
||||
public interface IOrganizationUpdateCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Updates an organization's information in the Bitwarden database and Stripe (if required).
|
||||
/// Also optionally updates an organization's public-private keypair if it was not created with one.
|
||||
/// On self-host, only the public-private keys will be updated because all other properties are fixed by the license file.
|
||||
/// </summary>
|
||||
/// <param name="request">The update request containing the details to be updated.</param>
|
||||
Task<Organization> UpdateAsync(OrganizationUpdateRequest request);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati
|
||||
PlanType = plan!.Type,
|
||||
Seats = signup.AdditionalSeats,
|
||||
MaxCollections = plan.PasswordManager.MaxCollections,
|
||||
MaxStorageGb = 1,
|
||||
MaxStorageGb = plan.PasswordManager.BaseStorageGb,
|
||||
UsePolicies = plan.HasPolicies,
|
||||
UseSso = plan.HasSso,
|
||||
UseOrganizationDomains = plan.HasOrganizationDomains,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
using Bit.Core.Billing.Organizations.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||
|
||||
public class OrganizationUpdateCommand(
|
||||
IOrganizationService organizationService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IGlobalSettings globalSettings,
|
||||
IOrganizationBillingService organizationBillingService
|
||||
) : IOrganizationUpdateCommand
|
||||
{
|
||||
public async Task<Organization> UpdateAsync(OrganizationUpdateRequest request)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(request.OrganizationId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (globalSettings.SelfHosted)
|
||||
{
|
||||
return await UpdateSelfHostedAsync(organization, request);
|
||||
}
|
||||
|
||||
return await UpdateCloudAsync(organization, request);
|
||||
}
|
||||
|
||||
private async Task<Organization> UpdateCloudAsync(Organization organization, OrganizationUpdateRequest request)
|
||||
{
|
||||
// Store original values for comparison
|
||||
var originalName = organization.Name;
|
||||
var originalBillingEmail = organization.BillingEmail;
|
||||
|
||||
// Apply updates to organization
|
||||
organization.UpdateDetails(request);
|
||||
organization.BackfillPublicPrivateKeys(request);
|
||||
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
|
||||
|
||||
// Update billing information in Stripe if required
|
||||
await UpdateBillingAsync(organization, originalName, originalBillingEmail);
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Self-host cannot update the organization details because they are set by the license file.
|
||||
/// However, this command does offer a soft migration pathway for organizations without public and private keys.
|
||||
/// If we remove this migration code in the future, this command and endpoint can become cloud only.
|
||||
/// </summary>
|
||||
private async Task<Organization> UpdateSelfHostedAsync(Organization organization, OrganizationUpdateRequest request)
|
||||
{
|
||||
organization.BackfillPublicPrivateKeys(request);
|
||||
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
|
||||
return organization;
|
||||
}
|
||||
|
||||
private async Task UpdateBillingAsync(Organization organization, string originalName, string? originalBillingEmail)
|
||||
{
|
||||
// Update Stripe if name or billing email changed
|
||||
var shouldUpdateBilling = originalName != organization.Name ||
|
||||
originalBillingEmail != organization.BillingEmail;
|
||||
|
||||
if (!shouldUpdateBilling || string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await organizationBillingService.UpdateOrganizationNameAndEmail(organization);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||
|
||||
public static class OrganizationUpdateExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Updates the organization name and/or billing email.
|
||||
/// Any null property on the request object will be skipped.
|
||||
/// </summary>
|
||||
public static void UpdateDetails(this Organization organization, OrganizationUpdateRequest request)
|
||||
{
|
||||
// These values may or may not be sent by the client depending on the operation being performed.
|
||||
// Skip any values not provided.
|
||||
if (request.Name is not null)
|
||||
{
|
||||
organization.Name = request.Name;
|
||||
}
|
||||
|
||||
if (request.BillingEmail is not null)
|
||||
{
|
||||
organization.BillingEmail = request.BillingEmail.ToLowerInvariant().Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the organization public and private keys if provided and not already set.
|
||||
/// This is legacy code for old organizations that were not created with a public/private keypair. It is a soft
|
||||
/// migration that will silently migrate organizations when they change their details.
|
||||
/// </summary>
|
||||
public static void BackfillPublicPrivateKeys(this Organization organization, OrganizationUpdateRequest request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.PublicKey) && string.IsNullOrWhiteSpace(organization.PublicKey))
|
||||
{
|
||||
organization.PublicKey = request.PublicKey;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.EncryptedPrivateKey) && string.IsNullOrWhiteSpace(organization.PrivateKey))
|
||||
{
|
||||
organization.PrivateKey = request.EncryptedPrivateKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for updating the name, billing email, and/or public-private keys for an organization (legacy migration code).
|
||||
/// Any combination of these properties can be updated, so they are optional. If none are specified it will not update anything.
|
||||
/// </summary>
|
||||
public record OrganizationUpdateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The ID of the organization to update.
|
||||
/// </summary>
|
||||
public required Guid OrganizationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The new organization name to apply (optional, this is skipped if not provided).
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The new billing email address to apply (optional, this is skipped if not provided).
|
||||
/// </summary>
|
||||
public string? BillingEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The organization's public key to set (optional, only set if not already present on the organization).
|
||||
/// </summary>
|
||||
public string? PublicKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The organization's encrypted private key to set (optional, only set if not already present on the organization).
|
||||
/// </summary>
|
||||
public string? EncryptedPrivateKey { get; init; }
|
||||
}
|
||||
@@ -9,6 +9,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
/// <summary>
|
||||
/// Defines behavior and functionality for a given PolicyType.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All methods defined in this interface are for the PolicyService#SavePolicy method. This needs to be supported until
|
||||
/// we successfully refactor policy validators over to policy validation handlers
|
||||
/// </remarks>
|
||||
public interface IPolicyValidator
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -5,4 +5,18 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
|
||||
public record SavePolicyModel(PolicyUpdate PolicyUpdate, IActingUser? PerformedBy, IPolicyMetadataModel Metadata)
|
||||
{
|
||||
public SavePolicyModel(PolicyUpdate PolicyUpdate)
|
||||
: this(PolicyUpdate, null, new EmptyMetadataModel())
|
||||
{
|
||||
}
|
||||
|
||||
public SavePolicyModel(PolicyUpdate PolicyUpdate, IActingUser performedBy)
|
||||
: this(PolicyUpdate, performedBy, new EmptyMetadataModel())
|
||||
{
|
||||
}
|
||||
|
||||
public SavePolicyModel(PolicyUpdate PolicyUpdate, IPolicyMetadataModel metadata)
|
||||
: this(PolicyUpdate, null, metadata)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
|
||||
public class SingleOrganizationPolicyRequirement(IEnumerable<PolicyDetails> policyDetails) : IPolicyRequirement
|
||||
{
|
||||
public bool IsSingleOrgEnabledForThisOrganization(Guid organizationId) =>
|
||||
policyDetails.Any(p => p.OrganizationId == organizationId);
|
||||
|
||||
public bool IsSingleOrgEnabledForOrganizationsOtherThan(Guid organizationId) =>
|
||||
policyDetails.Any(p => p.OrganizationId != organizationId);
|
||||
}
|
||||
|
||||
public class SingleOrganizationPolicyRequirementFactory : BasePolicyRequirementFactory<SingleOrganizationPolicyRequirement>
|
||||
{
|
||||
public override PolicyType PolicyType => PolicyType.SingleOrg;
|
||||
|
||||
public override SingleOrganizationPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails) =>
|
||||
new(policyDetails);
|
||||
}
|
||||
@@ -53,6 +53,8 @@ public static class PolicyServiceCollectionExtensions
|
||||
services.AddScoped<IPolicyUpdateEvent, FreeFamiliesForEnterprisePolicyValidator>();
|
||||
services.AddScoped<IPolicyUpdateEvent, OrganizationDataOwnershipPolicyValidator>();
|
||||
services.AddScoped<IPolicyUpdateEvent, UriMatchDefaultPolicyValidator>();
|
||||
services.AddScoped<IPolicyUpdateEvent, BlockClaimedDomainAccountCreationPolicyValidator>();
|
||||
services.AddScoped<IPolicyUpdateEvent, AutomaticUserConfirmationPolicyEventHandler>();
|
||||
}
|
||||
|
||||
private static void AddPolicyRequirements(this IServiceCollection services)
|
||||
@@ -64,5 +66,6 @@ public static class PolicyServiceCollectionExtensions
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireSsoPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireTwoFactorPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, MasterPasswordPolicyRequirementFactory>();
|
||||
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SingleOrganizationPolicyRequirementFactory>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Represents all policies required to be enabled before the given policy can be enabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface is intended for policy event handlers that mandate the activation of other policies
|
||||
/// as prerequisites for enabling the associated policy.
|
||||
/// </remarks>
|
||||
public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -3,6 +3,12 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Represents all side effects that should be executed before a policy is upserted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This should be added to policy handlers that need to perform side effects before policy upserts.
|
||||
/// </remarks>
|
||||
public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the policy to be upserted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is used for the VNextSavePolicyCommand. All policy handlers should implement this interface.
|
||||
/// </remarks>
|
||||
public interface IPolicyUpdateEvent
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -3,12 +3,17 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Represents all validations that need to be run to enable or disable the given policy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is used for the VNextSavePolicyCommand. This optional but should be implemented for all policies that have
|
||||
/// certain requirements for the given organization.
|
||||
/// </remarks>
|
||||
public interface IPolicyValidationEvent : IPolicyUpdateEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs side effects after a policy is validated but before it is saved.
|
||||
/// For example, this can be used to remove non-compliant users from the organization.
|
||||
/// Implementation is optional; by default, it will not perform any side effects.
|
||||
/// Performs any validations required to enable or disable the policy.
|
||||
/// </summary>
|
||||
/// <param name="policyRequest">The policy save request containing the policy update and metadata</param>
|
||||
/// <param name="currentPolicy">The current policy, if any</param>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an event handler for the Automatic User Confirmation policy.
|
||||
///
|
||||
/// This class validates that the following conditions are met:
|
||||
/// <ul>
|
||||
/// <li>The Single organization policy is enabled</li>
|
||||
/// <li>All organization users are compliant with the Single organization policy</li>
|
||||
/// <li>No provider users exist</li>
|
||||
/// </ul>
|
||||
///
|
||||
/// This class also performs side effects when the policy is being enabled or disabled. They are:
|
||||
/// <ul>
|
||||
/// <li>Sets the UseAutomaticUserConfirmation organization feature to match the policy update</li>
|
||||
/// </ul>
|
||||
/// </summary>
|
||||
public class AutomaticUserConfirmationPolicyEventHandler(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
TimeProvider timeProvider)
|
||||
: IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent
|
||||
{
|
||||
public PolicyType Type => PolicyType.AutomaticUserConfirmation;
|
||||
public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) =>
|
||||
await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy);
|
||||
|
||||
private const string _singleOrgPolicyNotEnabledErrorMessage =
|
||||
"The Single organization policy must be enabled before enabling the Automatically confirm invited users policy.";
|
||||
|
||||
private const string _usersNotCompliantWithSingleOrgErrorMessage =
|
||||
"All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations.";
|
||||
|
||||
private const string _providerUsersExistErrorMessage =
|
||||
"The organization has users with the Provider user type. Please remove provider users before enabling the Automatically confirm invited users policy.";
|
||||
|
||||
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
|
||||
|
||||
public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
|
||||
{
|
||||
var isNotEnablingPolicy = policyUpdate is not { Enabled: true };
|
||||
var policyAlreadyEnabled = currentPolicy is { Enabled: true };
|
||||
if (isNotEnablingPolicy || policyAlreadyEnabled)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return await ValidateEnablingPolicyAsync(policyUpdate.OrganizationId);
|
||||
}
|
||||
|
||||
public async Task<string> ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) =>
|
||||
await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy);
|
||||
|
||||
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(policyUpdate.OrganizationId);
|
||||
|
||||
if (organization is not null)
|
||||
{
|
||||
organization.UseAutomaticUserConfirmation = policyUpdate.Enabled;
|
||||
organization.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
|
||||
await organizationRepository.UpsertAsync(organization);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ValidateEnablingPolicyAsync(Guid organizationId)
|
||||
{
|
||||
var singleOrgValidationError = await ValidateSingleOrgPolicyComplianceAsync(organizationId);
|
||||
if (!string.IsNullOrWhiteSpace(singleOrgValidationError))
|
||||
{
|
||||
return singleOrgValidationError;
|
||||
}
|
||||
|
||||
var providerValidationError = await ValidateNoProviderUsersAsync(organizationId);
|
||||
if (!string.IsNullOrWhiteSpace(providerValidationError))
|
||||
{
|
||||
return providerValidationError;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private async Task<string> ValidateSingleOrgPolicyComplianceAsync(Guid organizationId)
|
||||
{
|
||||
var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg);
|
||||
if (singleOrgPolicy is not { Enabled: true })
|
||||
{
|
||||
return _singleOrgPolicyNotEnabledErrorMessage;
|
||||
}
|
||||
|
||||
return await ValidateUserComplianceWithSingleOrgAsync(organizationId);
|
||||
}
|
||||
|
||||
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId)
|
||||
{
|
||||
var organizationUsers = (await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId))
|
||||
.Where(ou => ou.Status != OrganizationUserStatusType.Invited &&
|
||||
ou.Status != OrganizationUserStatusType.Revoked &&
|
||||
ou.UserId.HasValue)
|
||||
.ToList();
|
||||
|
||||
if (organizationUsers.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
|
||||
organizationUsers.Select(ou => ou.UserId!.Value)))
|
||||
.Any(uo => uo.OrganizationId != organizationId &&
|
||||
uo.Status != OrganizationUserStatusType.Invited);
|
||||
|
||||
return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty;
|
||||
}
|
||||
|
||||
private async Task<string> ValidateNoProviderUsersAsync(Guid organizationId)
|
||||
{
|
||||
var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId);
|
||||
|
||||
return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
|
||||
public class BlockClaimedDomainAccountCreationPolicyValidator : IPolicyValidator, IPolicyValidationEvent
|
||||
{
|
||||
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public BlockClaimedDomainAccountCreationPolicyValidator(
|
||||
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
public PolicyType Type => PolicyType.BlockClaimedDomainAccountCreation;
|
||||
|
||||
// No prerequisites - this policy stands alone
|
||||
public IEnumerable<PolicyType> RequiredPolicies => [];
|
||||
|
||||
public async Task<string> ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy)
|
||||
{
|
||||
return await ValidateAsync(policyRequest.PolicyUpdate, currentPolicy);
|
||||
}
|
||||
|
||||
public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
|
||||
{
|
||||
// Check if feature is enabled
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation))
|
||||
{
|
||||
return "This feature is not enabled";
|
||||
}
|
||||
|
||||
// Only validate when trying to ENABLE the policy
|
||||
if (policyUpdate is { Enabled: true })
|
||||
{
|
||||
// Check if organization has at least one verified domain
|
||||
if (!await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId))
|
||||
{
|
||||
return "You must claim at least one domain to turn on this policy";
|
||||
}
|
||||
}
|
||||
|
||||
// Disabling the policy is always allowed
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
@@ -29,8 +27,6 @@ public class SingleOrgPolicyValidator : IPolicyValidator, IPolicyValidationEvent
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
|
||||
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
|
||||
private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand;
|
||||
|
||||
@@ -40,8 +36,6 @@ public class SingleOrgPolicyValidator : IPolicyValidator, IPolicyValidationEvent
|
||||
IOrganizationRepository organizationRepository,
|
||||
ISsoConfigRepository ssoConfigRepository,
|
||||
ICurrentContext currentContext,
|
||||
IFeatureService featureService,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
|
||||
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand)
|
||||
{
|
||||
@@ -50,8 +44,6 @@ public class SingleOrgPolicyValidator : IPolicyValidator, IPolicyValidationEvent
|
||||
_organizationRepository = organizationRepository;
|
||||
_ssoConfigRepository = ssoConfigRepository;
|
||||
_currentContext = currentContext;
|
||||
_featureService = featureService;
|
||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||
_revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user