1
0
mirror of https://github.com/bitwarden/server synced 2025-12-19 09:43:25 +00:00

Merge branch 'main' into SM-1571-DisableSMAdsForUsers

This commit is contained in:
cd-bitwarden
2025-10-28 14:10:57 -04:00
committed by GitHub
96 changed files with 17627 additions and 1014 deletions

View File

@@ -0,0 +1,25 @@
Please review this pull request with a focus on:
- Code quality and best practices
- Potential bugs or issues
- Security implications
- Performance considerations
Note: The PR branch is already checked out in the current working directory.
Provide a comprehensive review including:
- Summary of changes since last review
- Critical issues found (be thorough)
- Suggested improvements (be thorough)
- Good practices observed (be concise - list only the most notable items without elaboration)
- Action items for the author
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
When reviewing subsequent commits:
- Track status of previously identified issues (fixed/unfixed/reopened)
- Identify NEW problems introduced since last review
- Note if fixes introduced new issues
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.

5
.github/CODEOWNERS vendored
View File

@@ -102,3 +102,8 @@ util/RustSdk @bitwarden/team-sdk-sme
# Multiple owners - DO NOT REMOVE (BRE) # Multiple owners - DO NOT REMOVE (BRE)
**/packages.lock.json **/packages.lock.json
Directory.Build.props Directory.Build.props
# Claude related files
.claude/ @bitwarden/team-ai-sme
.github/workflows/respond.yml @bitwarden/team-ai-sme
.github/workflows/review-code.yml @bitwarden/team-ai-sme

28
.github/workflows/respond.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Respond
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
permissions: {}
jobs:
respond:
name: Respond
uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
actions: read
contents: write
id-token: write
issues: write
pull-requests: write

View File

@@ -1,124 +1,20 @@
name: Review code name: Code Review
on: on:
pull_request: pull_request:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened, ready_for_review]
permissions: {} permissions: {}
jobs: jobs:
review: review:
name: Review name: Review
runs-on: ubuntu-24.04 uses: bitwarden/gh-actions/.github/workflows/_review-code.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions: permissions:
contents: read contents: read
id-token: write id-token: write
pull-requests: write pull-requests: write
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Check for Vault team changes
id: check_changes
run: |
# Ensure we have the base branch
git fetch origin "${GITHUB_BASE_REF}"
echo "Comparing changes between origin/${GITHUB_BASE_REF} and HEAD"
CHANGED_FILES=$(git diff --name-only "origin/${GITHUB_BASE_REF}...HEAD")
if [ -z "$CHANGED_FILES" ]; then
echo "Zero files changed"
echo "vault_team_changes=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Handle variations in spacing and multiple teams
VAULT_PATTERNS=$(grep -E "@bitwarden/team-vault-dev(\s|$)" .github/CODEOWNERS 2>/dev/null | awk '{print $1}')
if [ -z "$VAULT_PATTERNS" ]; then
echo "⚠️ No patterns found for @bitwarden/team-vault-dev in CODEOWNERS"
echo "vault_team_changes=false" >> "$GITHUB_OUTPUT"
exit 0
fi
vault_team_changes=false
for pattern in $VAULT_PATTERNS; do
echo "Checking pattern: $pattern"
# Handle **/directory patterns
if [[ "$pattern" == "**/"* ]]; then
# Remove the **/ prefix
dir_pattern="${pattern#\*\*/}"
# Check if any file contains this directory in its path
if echo "$CHANGED_FILES" | grep -qE "(^|/)${dir_pattern}(/|$)"; then
vault_team_changes=true
echo "✅ Found files matching pattern: $pattern"
echo "$CHANGED_FILES" | grep -E "(^|/)${dir_pattern}(/|$)" | sed 's/^/ - /'
break
fi
else
# Handle other patterns (shouldn't happen based on your CODEOWNERS)
if echo "$CHANGED_FILES" | grep -q "$pattern"; then
vault_team_changes=true
echo "✅ Found files matching pattern: $pattern"
echo "$CHANGED_FILES" | grep "$pattern" | sed 's/^/ - /'
break
fi
fi
done
echo "vault_team_changes=$vault_team_changes" >> "$GITHUB_OUTPUT"
if [ "$vault_team_changes" = "true" ]; then
echo ""
echo "✅ Vault team changes detected - proceeding with review"
else
echo ""
echo "❌ No Vault team changes detected - skipping review"
fi
- name: Review with Claude Code
if: steps.check_changes.outputs.vault_team_changes == 'true'
uses: anthropics/claude-code-action@ac1a3207f3f00b4a37e2f3a6f0935733c7c64651 # v1.0.11
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
track_progress: true
use_sticky_comment: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
TITLE: ${{ github.event.pull_request.title }}
BODY: ${{ github.event.pull_request.body }}
AUTHOR: ${{ github.event.pull_request.user.login }}
COMMIT: ${{ github.event.pull_request.head.sha }}
Please review this pull request with a focus on:
- Code quality and best practices
- Potential bugs or issues
- Security implications
- Performance considerations
Note: The PR branch is already checked out in the current working directory.
Provide a comprehensive review including:
- Summary of changes since last review
- Critical issues found (be thorough)
- Suggested improvements (be thorough)
- Good practices observed (be concise - list only the most notable items without elaboration)
- Action items for the author
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
When reviewing subsequent commits:
- Track status of previously identified issues (fixed/unfixed/reopened)
- Identify NEW problems introduced since last review
- Note if fixes introduced new issues
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.
claude_args: |
--allowedTools "mcp__github_comment__update_claude_comment,mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*)"

2
.gitignore vendored
View File

@@ -234,4 +234,6 @@ bitwarden_license/src/Sso/Sso.zip
/identity.json /identity.json
/api.json /api.json
/api.public.json /api.public.json
# Serena
.serena/ .serena/

View File

@@ -136,6 +136,8 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -348,6 +350,10 @@ Global
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -404,6 +410,7 @@ Global
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@@ -57,6 +57,7 @@ public class AccountController : Controller
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector; private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IOrganizationDomainRepository _organizationDomainRepository;
private readonly IRegisterUserCommand _registerUserCommand; private readonly IRegisterUserCommand _registerUserCommand;
private readonly IFeatureService _featureService;
public AccountController( public AccountController(
IAuthenticationSchemeProvider schemeProvider, IAuthenticationSchemeProvider schemeProvider,
@@ -77,7 +78,8 @@ public class AccountController : Controller
Core.Services.IEventService eventService, Core.Services.IEventService eventService,
IDataProtectorTokenFactory<SsoTokenable> dataProtector, IDataProtectorTokenFactory<SsoTokenable> dataProtector,
IOrganizationDomainRepository organizationDomainRepository, IOrganizationDomainRepository organizationDomainRepository,
IRegisterUserCommand registerUserCommand) IRegisterUserCommand registerUserCommand,
IFeatureService featureService)
{ {
_schemeProvider = schemeProvider; _schemeProvider = schemeProvider;
_clientStore = clientStore; _clientStore = clientStore;
@@ -98,10 +100,11 @@ public class AccountController : Controller
_dataProtector = dataProtector; _dataProtector = dataProtector;
_organizationDomainRepository = organizationDomainRepository; _organizationDomainRepository = organizationDomainRepository;
_registerUserCommand = registerUserCommand; _registerUserCommand = registerUserCommand;
_featureService = featureService;
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> PreValidate(string domainHint) public async Task<IActionResult> PreValidateAsync(string domainHint)
{ {
try try
{ {
@@ -160,7 +163,7 @@ public class AccountController : Controller
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> Login(string returnUrl) public async Task<IActionResult> LoginAsync(string returnUrl)
{ {
var context = await _interaction.GetAuthorizationContextAsync(returnUrl); var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
@@ -235,32 +238,91 @@ public class AccountController : Controller
[HttpGet] [HttpGet]
public async Task<IActionResult> ExternalCallback() public async Task<IActionResult> ExternalCallback()
{ {
// Feature flag (PM-24579): Prevent SSO on existing non-compliant users.
var preventOrgUserLoginIfStatusInvalid =
_featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers);
// Read external identity from the temporary cookie // Read external identity from the temporary cookie
var result = await HttpContext.AuthenticateAsync( var result = await HttpContext.AuthenticateAsync(
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
if (preventOrgUserLoginIfStatusInvalid)
{
if (!result.Succeeded)
{
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
}
}
else
{
if (result?.Succeeded != true) if (result?.Succeeded != true)
{ {
throw new Exception(_i18nService.T("ExternalAuthenticationError")); throw new Exception(_i18nService.T("ExternalAuthenticationError"));
} }
}
// Debugging
var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
_logger.LogDebug("External claims: {@claims}", externalClaims);
// See if the user has logged in with this SSO provider before and has already been provisioned. // 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. // 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 (user, 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;
// The user has not authenticated with this SSO provider before. // The user has not authenticated with this SSO provider before.
// They could have an existing Bitwarden account in the User table though. // They could have an existing Bitwarden account in the User table though.
if (user == null) if (user == null)
{ {
// If we're manually linking to SSO, the user's external identifier will be passed as query string parameter. // 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") ? var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier")
result.Properties.Items["user_identifier"] : null; ? result.Properties.Items["user_identifier"]
user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData); : null;
var (provisionedUser, foundOrganization, foundOrCreatedOrgUser) =
await AutoProvisionUserAsync(
provider,
providerUserId,
claims,
userIdentifier,
ssoConfigData);
user = provisionedUser;
if (preventOrgUserLoginIfStatusInvalid)
{
organization = foundOrganization;
orgUser = foundOrCreatedOrgUser;
}
} }
if (preventOrgUserLoginIfStatusInvalid)
{
if (user == null) throw new Exception(_i18nService.T("UserShouldBeFound"));
await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, user);
// This allows us to collect any additional claims or properties
// for the specific protocols used and store them in the local auth cookie.
// this is typically used to store data needed for signout from those protocols.
var additionalLocalClaims = new List<Claim>();
var localSignInProps = new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1)
};
ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);
// Issue authentication cookie for user
await HttpContext.SignInAsync(
new IdentityServerUser(user.Id.ToString())
{
DisplayName = user.Email,
IdentityProvider = provider,
AdditionalClaims = additionalLocalClaims.ToArray()
}, localSignInProps);
}
else
{
// Either the user already authenticated with the SSO provider, or we've just provisioned them. // 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. // Either way, we have associated the SSO login with a Bitwarden user.
// We will now sign the Bitwarden user in. // We will now sign the Bitwarden user in.
@@ -278,13 +340,15 @@ public class AccountController : Controller
ProcessLoginCallback(result, additionalLocalClaims, localSignInProps); ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);
// Issue authentication cookie for user // Issue authentication cookie for user
await HttpContext.SignInAsync(new IdentityServerUser(user.Id.ToString()) await HttpContext.SignInAsync(
new IdentityServerUser(user.Id.ToString())
{ {
DisplayName = user.Email, DisplayName = user.Email,
IdentityProvider = provider, IdentityProvider = provider,
AdditionalClaims = additionalLocalClaims.ToArray() AdditionalClaims = additionalLocalClaims.ToArray()
}, localSignInProps); }, localSignInProps);
} }
}
// Delete temporary cookie used during external authentication // Delete temporary cookie used during external authentication
await HttpContext.SignOutAsync(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); await HttpContext.SignOutAsync(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
@@ -310,7 +374,7 @@ public class AccountController : Controller
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> Logout(string logoutId) public async Task<IActionResult> LogoutAsync(string logoutId)
{ {
// Build a model so the logged out page knows what to display // Build a model so the logged out page knows what to display
var (updatedLogoutId, redirectUri, externalAuthenticationScheme) = await GetLoggedOutDataAsync(logoutId); var (updatedLogoutId, redirectUri, externalAuthenticationScheme) = await GetLoggedOutDataAsync(logoutId);
@@ -333,6 +397,7 @@ public class AccountController : Controller
// This triggers a redirect to the external provider for sign-out // This triggers a redirect to the external provider for sign-out
return SignOut(new AuthenticationProperties { RedirectUri = url }, externalAuthenticationScheme); return SignOut(new AuthenticationProperties { RedirectUri = url }, externalAuthenticationScheme);
} }
if (redirectUri != null) if (redirectUri != null)
{ {
return View("Redirect", new RedirectViewModel { RedirectUrl = redirectUri }); return View("Redirect", new RedirectViewModel { RedirectUrl = redirectUri });
@@ -347,7 +412,8 @@ public class AccountController : Controller
/// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`. /// 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. /// 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> /// </summary>
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims, SsoConfigurationData config)> private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims,
SsoConfigurationData config)>
FindUserFromExternalProviderAsync(AuthenticateResult result) FindUserFromExternalProviderAsync(AuthenticateResult result)
{ {
var provider = result.Properties.Items["scheme"]; var provider = result.Properties.Items["scheme"];
@@ -375,7 +441,8 @@ public class AccountController : Controller
// for the user identifier. // for the user identifier.
static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier
&& (c.Properties == null && (c.Properties == null
|| !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat, out var claimFormat) || !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat,
out var claimFormat)
|| claimFormat != SamlNameIdFormats.Transient); || claimFormat != SamlNameIdFormats.Transient);
// Try to determine the unique id of the external user (issued by the provider) // Try to determine the unique id of the external user (issued by the provider)
@@ -418,24 +485,20 @@ public class AccountController : Controller
/// <param name="providerUserId">The external identity provider's user identifier.</param> /// <param name="providerUserId">The external identity provider's user identifier.</param>
/// <param name="claims">The claims from the external IdP.</param> /// <param name="claims">The claims from the external IdP.</param>
/// <param name="userIdentifier">The user identifier used for manual SSO linking.</param> /// <param name="userIdentifier">The user identifier used for manual SSO linking.</param>
/// <param name="config">The SSO configuration for the organization.</param> /// <param name="ssoConfigData">The SSO configuration for the organization.</param>
/// <returns>The User to sign in.</returns> /// <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> /// <exception cref="Exception">An exception if the user cannot be provisioned as requested.</exception>
private async Task<User> AutoProvisionUserAsync(string provider, string providerUserId, private async Task<(User user, Organization foundOrganization, OrganizationUser foundOrgUser)>
IEnumerable<Claim> claims, string userIdentifier, SsoConfigurationData config) AutoProvisionUserAsync(
string provider,
string providerUserId,
IEnumerable<Claim> claims,
string userIdentifier,
SsoConfigurationData ssoConfigData
)
{ {
var name = GetName(claims, config.GetAdditionalNameClaimTypes()); var name = GetName(claims, ssoConfigData.GetAdditionalNameClaimTypes());
var email = GetEmailAddress(claims, config.GetAdditionalEmailClaimTypes()); var email = TryGetEmailAddress(claims, ssoConfigData, providerUserId);
if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@"))
{
email = providerUserId;
}
if (!Guid.TryParse(provider, out var orgId))
{
// TODO: support non-org (server-wide) SSO in the future?
throw new Exception(_i18nService.T("SSOProviderIsNotAnOrgId", provider));
}
User existingUser = null; User existingUser = null;
if (string.IsNullOrWhiteSpace(userIdentifier)) if (string.IsNullOrWhiteSpace(userIdentifier))
@@ -444,15 +507,19 @@ public class AccountController : Controller
{ {
throw new Exception(_i18nService.T("CannotFindEmailClaim")); throw new Exception(_i18nService.T("CannotFindEmailClaim"));
} }
existingUser = await _userRepository.GetByEmailAsync(email); existingUser = await _userRepository.GetByEmailAsync(email);
} }
else else
{ {
existingUser = await GetUserFromManualLinkingData(userIdentifier); existingUser = await GetUserFromManualLinkingDataAsync(userIdentifier);
} }
// Try to find the OrganizationUser if it exists. // Try to find the org (we error if we can't find an org)
var (organization, orgUser) = await FindOrganizationUser(existingUser, email, orgId); var organization = await TryGetOrganizationByProviderAsync(provider);
// Try to find an org user (null org user possible and valid here)
var orgUser = await TryGetOrganizationUserByUserAndOrgOrEmail(existingUser, organization.Id, email);
//---------------------------------------------------- //----------------------------------------------------
// Scenario 1: We've found the user in the User table // Scenario 1: We've found the user in the User table
@@ -473,22 +540,22 @@ public class AccountController : Controller
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess")); throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
} }
EnsureOrgUserStatusAllowed(orgUser.Status, organization.DisplayName(), EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName());
allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]);
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not // 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). // 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 // We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
// with authentication. // with authentication.
await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser); await CreateSsoUserRecordAsync(providerUserId, existingUser.Id, organization.Id, orgUser);
return existingUser;
return (existingUser, organization, orgUser);
} }
// Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one // 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 (orgUser == null && organization.Seats.HasValue)
{ {
var occupiedSeats = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var occupiedSeats =
await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var initialSeatCount = organization.Seats.Value; var initialSeatCount = organization.Seats.Value;
var availableSeats = initialSeatCount - occupiedSeats.Total; var availableSeats = initialSeatCount - occupiedSeats.Total;
if (availableSeats < 1) if (availableSeats < 1)
@@ -506,8 +573,10 @@ public class AccountController : Controller
{ {
if (organization.Seats.Value != initialSeatCount) if (organization.Seats.Value != initialSeatCount)
{ {
await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value); await _organizationService.AdjustSeatsAsync(organization.Id,
initialSeatCount - organization.Seats.Value);
} }
_logger.LogInformation(e, "SSO auto provisioning failed"); _logger.LogInformation(e, "SSO auto provisioning failed");
throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName())); throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName()));
} }
@@ -519,7 +588,8 @@ public class AccountController : Controller
var emailDomain = CoreHelpers.GetEmailDomain(email); var emailDomain = CoreHelpers.GetEmailDomain(email);
if (!string.IsNullOrWhiteSpace(emailDomain)) if (!string.IsNullOrWhiteSpace(emailDomain))
{ {
var organizationDomain = await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(orgId, emailDomain); var organizationDomain =
await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(organization.Id, emailDomain);
emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false; emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false;
} }
@@ -537,7 +607,7 @@ public class AccountController : Controller
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy = var twoFactorPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.TwoFactorAuthentication); await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy != null && twoFactorPolicy.Enabled) if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
{ {
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider> user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
@@ -560,13 +630,14 @@ public class AccountController : Controller
{ {
orgUser = new OrganizationUser orgUser = new OrganizationUser
{ {
OrganizationId = orgId, OrganizationId = organization.Id,
UserId = user.Id, UserId = user.Id,
Type = OrganizationUserType.User, Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Invited Status = OrganizationUserStatusType.Invited
}; };
await _organizationUserRepository.CreateAsync(orgUser); await _organizationUserRepository.CreateAsync(orgUser);
} }
//----------------------------------------------------------------- //-----------------------------------------------------------------
// Scenario 3: There is already an existing OrganizationUser // Scenario 3: There is already an existing OrganizationUser
// That was established through an invitation. We just need to // That was established through an invitation. We just need to
@@ -579,12 +650,47 @@ public class AccountController : Controller
} }
// Create the SsoUser record to link the user to the SSO provider. // Create the SsoUser record to link the user to the SSO provider.
await CreateSsoUserRecord(providerUserId, user.Id, orgId, orgUser); await CreateSsoUserRecordAsync(providerUserId, user.Id, organization.Id, orgUser);
return user; return (user, organization, orgUser);
} }
private async Task<User> GetUserFromManualLinkingData(string userIdentifier) /// <summary>
/// Validates an organization user is allowed to log in via SSO and blocks invalid statuses.
/// Lazily resolves the organization and organization user if not provided.
/// </summary>
/// <param name="organization">The target organization; if null, resolved from provider.</param>
/// <param name="provider">The SSO scheme provider value (organization id as a GUID string).</param>
/// <param name="orgUser">The organization-user record; if null, looked up by user/org or user email for invited users.</param>
/// <param name="user">The user attempting to sign in (existing or newly provisioned).</param>
/// <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,
string provider,
OrganizationUser orgUser,
User user)
{
// Lazily get organization if not already known
organization ??= await TryGetOrganizationByProviderAsync(provider);
// Lazily get the org user if not already known
orgUser ??= await TryGetOrganizationUserByUserAndOrgOrEmail(
user,
organization.Id,
user.Email);
if (orgUser != null)
{
EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName());
}
else
{
throw new Exception(_i18nService.T("CouldNotFindOrganizationUser", user.Id, organization.Id));
}
}
private async Task<User> GetUserFromManualLinkingDataAsync(string userIdentifier)
{ {
User user = null; User user = null;
var split = userIdentifier.Split(","); var split = userIdentifier.Split(",");
@@ -592,6 +698,7 @@ public class AccountController : Controller
{ {
throw new Exception(_i18nService.T("InvalidUserIdentifier")); throw new Exception(_i18nService.T("InvalidUserIdentifier"));
} }
var userId = split[0]; var userId = split[0];
var token = split[1]; var token = split[1];
@@ -611,38 +718,73 @@ public class AccountController : Controller
throw new Exception(_i18nService.T("UserIdAndTokenMismatch")); throw new Exception(_i18nService.T("UserIdAndTokenMismatch"));
} }
} }
return user; return user;
} }
private async Task<(Organization, OrganizationUser)> FindOrganizationUser(User existingUser, string email, Guid orgId) /// <summary>
/// Tries to get the organization by the provider which is org id for us as we use the scheme
/// to identify organizations - not identity providers.
/// </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)
{ {
OrganizationUser orgUser = null; if (!Guid.TryParse(provider, out var organizationId))
var organization = await _organizationRepository.GetByIdAsync(orgId); {
// TODO: support non-org (server-wide) SSO in the future?
throw new Exception(_i18nService.T("SSOProviderIsNotAnOrgId", provider));
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
if (organization == null) if (organization == null)
{ {
throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId)); throw new Exception(_i18nService.T("CouldNotFindOrganization", organizationId));
} }
return organization;
}
/// <summary>
/// Attempts to get an <see cref="OrganizationUser"/> for a given organization
/// by first checking for an existing user relationship, and if none is found,
/// by looking up an invited user via their email address.
/// </summary>
/// <param name="user">The existing user entity to be looked up in OrganizationUsers table.</param>
/// <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,
Guid organizationId,
string email)
{
OrganizationUser orgUser = null;
// Try to find OrgUser via existing User Id. // Try to find OrgUser via existing User Id.
// This covers any OrganizationUser state after they have accepted an invite. // This covers any OrganizationUser state after they have accepted an invite.
if (existingUser != null) if (user != null)
{ {
var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id); var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(user.Id);
orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId); orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == organizationId);
} }
// If no Org User found by Existing User Id - search all the organization's users via email. // 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. // This covers users who are Invited but haven't accepted their invite yet.
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email); orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email);
return (organization, orgUser); return orgUser;
} }
private void EnsureOrgUserStatusAllowed( private void EnsureAcceptedOrConfirmedOrgUserStatus(
OrganizationUserStatusType status, OrganizationUserStatusType status,
string organizationDisplayName, string organizationDisplayName)
params OrganizationUserStatusType[] allowedStatuses)
{ {
// 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 this status is one of the allowed ones, just return
if (allowedStatuses.Contains(status)) if (allowedStatuses.Contains(status))
{ {
@@ -667,7 +809,6 @@ public class AccountController : Controller
} }
} }
private IActionResult InvalidJson(string errorMessageKey, Exception ex = null) private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
{ {
Response.StatusCode = ex == null ? 400 : 500; Response.StatusCode = ex == null ? 400 : 500;
@@ -679,7 +820,7 @@ public class AccountController : Controller
}); });
} }
private string GetEmailAddress(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("@")); var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@"));
@@ -725,7 +866,8 @@ public class AccountController : Controller
return null; return null;
} }
private async Task CreateSsoUserRecord(string providerUserId, Guid userId, Guid orgId, OrganizationUser orgUser) private async Task CreateSsoUserRecordAsync(string providerUserId, Guid userId, Guid orgId,
OrganizationUser orgUser)
{ {
// Delete existing SsoUser (if any) - avoids error if providerId has changed and the sso link is stale // Delete existing SsoUser (if any) - avoids error if providerId has changed and the sso link is stale
var existingSsoUser = await _ssoUserRepository.GetByUserIdOrganizationIdAsync(orgId, userId); var existingSsoUser = await _ssoUserRepository.GetByUserIdOrganizationIdAsync(orgId, userId);
@@ -740,12 +882,7 @@ public class AccountController : Controller
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_FirstSsoLogin); await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_FirstSsoLogin);
} }
var ssoUser = new SsoUser var ssoUser = new SsoUser { ExternalId = providerUserId, UserId = userId, OrganizationId = orgId, };
{
ExternalId = providerUserId,
UserId = userId,
OrganizationId = orgId,
};
await _ssoUserRepository.CreateAsync(ssoUser); await _ssoUserRepository.CreateAsync(ssoUser);
} }
@@ -769,18 +906,6 @@ public class AccountController : Controller
} }
} }
private async Task<string> GetProviderAsync(string returnUrl)
{
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (context?.IdP != null && await _schemeProvider.GetSchemeAsync(context.IdP) != null)
{
return context.IdP;
}
var schemes = await _schemeProvider.GetAllSchemesAsync();
var providers = schemes.Select(x => x.Name).ToList();
return providers.FirstOrDefault();
}
private async Task<(string, string, string)> GetLoggedOutDataAsync(string logoutId) private async Task<(string, string, string)> GetLoggedOutDataAsync(string logoutId)
{ {
// Get context information (client name, post logout redirect URI and iframe for federated signout) // Get context information (client name, post logout redirect URI and iframe for federated signout)
@@ -812,6 +937,26 @@ public class AccountController : Controller
return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme); return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme);
} }
/**
* 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(
IEnumerable<Claim> claims,
SsoConfigurationData config,
string providerUserId)
{
var email = TryGetEmailAddressFromClaims(claims, config.GetAdditionalEmailClaimTypes());
// If email isn't populated from claims and providerUserId has @, assume it is the email.
if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@"))
{
email = providerUserId;
}
return email;
}
public bool IsNativeClient(DIM.AuthorizationRequest context) public bool IsNativeClient(DIM.AuthorizationRequest context)
{ {
return !context.RedirectUri.StartsWith("https", StringComparison.Ordinal) return !context.RedirectUri.StartsWith("https", StringComparison.Ordinal)

View File

@@ -17,7 +17,7 @@
"css-loader": "7.1.2", "css-loader": "7.1.2",
"expose-loader": "5.0.1", "expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.91.0", "sass": "1.93.2",
"sass-loader": "16.0.5", "sass-loader": "16.0.5",
"webpack": "5.101.3", "webpack": "5.101.3",
"webpack-cli": "5.1.4" "webpack-cli": "5.1.4"
@@ -678,6 +678,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -704,6 +705,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -799,6 +801,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001737", "caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211", "electron-to-chromium": "^1.5.211",
@@ -1653,6 +1656,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -1859,11 +1863,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.91.0", "version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"immutable": "^5.0.2", "immutable": "^5.0.2",
@@ -2206,6 +2211,7 @@
"integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/eslint-scope": "^3.7.7", "@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8", "@types/estree": "^1.0.8",
@@ -2255,6 +2261,7 @@
"integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@discoveryjs/json-ext": "^0.5.0", "@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^2.1.1", "@webpack-cli/configtest": "^2.1.1",

View File

@@ -16,7 +16,7 @@
"css-loader": "7.1.2", "css-loader": "7.1.2",
"expose-loader": "5.0.1", "expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.91.0", "sass": "1.93.2",
"sass-loader": "16.0.5", "sass-loader": "16.0.5",
"webpack": "5.101.3", "webpack": "5.101.3",
"webpack-cli": "5.1.4" "webpack-cli": "5.1.4"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Sso\Sso.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -18,7 +18,7 @@
"css-loader": "7.1.2", "css-loader": "7.1.2",
"expose-loader": "5.0.1", "expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.91.0", "sass": "1.93.2",
"sass-loader": "16.0.5", "sass-loader": "16.0.5",
"webpack": "5.101.3", "webpack": "5.101.3",
"webpack-cli": "5.1.4" "webpack-cli": "5.1.4"
@@ -679,6 +679,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -705,6 +706,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -800,6 +802,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001737", "caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211", "electron-to-chromium": "^1.5.211",
@@ -1654,6 +1657,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -1860,11 +1864,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.91.0", "version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"immutable": "^5.0.2", "immutable": "^5.0.2",
@@ -2215,6 +2220,7 @@
"integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/eslint-scope": "^3.7.7", "@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8", "@types/estree": "^1.0.8",
@@ -2264,6 +2270,7 @@
"integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@discoveryjs/json-ext": "^0.5.0", "@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^2.1.1", "@webpack-cli/configtest": "^2.1.1",

View File

@@ -17,7 +17,7 @@
"css-loader": "7.1.2", "css-loader": "7.1.2",
"expose-loader": "5.0.1", "expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.91.0", "sass": "1.93.2",
"sass-loader": "16.0.5", "sass-loader": "16.0.5",
"webpack": "5.101.3", "webpack": "5.101.3",
"webpack-cli": "5.1.4" "webpack-cli": "5.1.4"

View File

@@ -0,0 +1,127 @@
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Response;
/// <summary>
/// Contains organization properties for both OrganizationUsers and ProviderUsers.
/// Any organization properties in sync data should be added to this class so they are populated for both
/// members and providers.
/// </summary>
public abstract class BaseProfileOrganizationResponseModel : ResponseModel
{
protected BaseProfileOrganizationResponseModel(
string type, IProfileOrganizationDetails organizationDetails) : base(type)
{
Id = organizationDetails.OrganizationId;
UserId = organizationDetails.UserId;
Name = organizationDetails.Name;
Enabled = organizationDetails.Enabled;
Identifier = organizationDetails.Identifier;
ProductTierType = organizationDetails.PlanType.GetProductTier();
UsePolicies = organizationDetails.UsePolicies;
UseSso = organizationDetails.UseSso;
UseKeyConnector = organizationDetails.UseKeyConnector;
UseScim = organizationDetails.UseScim;
UseGroups = organizationDetails.UseGroups;
UseDirectory = organizationDetails.UseDirectory;
UseEvents = organizationDetails.UseEvents;
UseTotp = organizationDetails.UseTotp;
Use2fa = organizationDetails.Use2fa;
UseApi = organizationDetails.UseApi;
UseResetPassword = organizationDetails.UseResetPassword;
UsersGetPremium = organizationDetails.UsersGetPremium;
UseCustomPermissions = organizationDetails.UseCustomPermissions;
UseActivateAutofillPolicy = organizationDetails.PlanType.GetProductTier() == ProductTierType.Enterprise;
UseRiskInsights = organizationDetails.UseRiskInsights;
UseOrganizationDomains = organizationDetails.UseOrganizationDomains;
UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
UseSecretsManager = organizationDetails.UseSecretsManager;
UsePasswordManager = organizationDetails.UsePasswordManager;
SelfHost = organizationDetails.SelfHost;
Seats = organizationDetails.Seats;
MaxCollections = organizationDetails.MaxCollections;
MaxStorageGb = organizationDetails.MaxStorageGb;
Key = organizationDetails.Key;
HasPublicAndPrivateKeys = organizationDetails.PublicKey != null && organizationDetails.PrivateKey != null;
SsoBound = !string.IsNullOrWhiteSpace(organizationDetails.SsoExternalId);
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organizationDetails.ResetPasswordKey);
ProviderId = organizationDetails.ProviderId;
ProviderName = organizationDetails.ProviderName;
ProviderType = organizationDetails.ProviderType;
LimitCollectionCreation = organizationDetails.LimitCollectionCreation;
LimitCollectionDeletion = organizationDetails.LimitCollectionDeletion;
LimitItemDeletion = organizationDetails.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organizationDetails.AllowAdminAccessToAllCollectionItems;
SsoEnabled = organizationDetails.SsoEnabled ?? false;
if (organizationDetails.SsoConfig != null)
{
var ssoConfigData = SsoConfigurationData.Deserialize(organizationDetails.SsoConfig);
KeyConnectorEnabled = ssoConfigData.MemberDecryptionType == MemberDecryptionType.KeyConnector && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl);
KeyConnectorUrl = ssoConfigData.KeyConnectorUrl;
SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType;
}
}
public Guid Id { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; } = null!;
public bool Enabled { get; set; }
public string? Identifier { get; set; }
public ProductTierType ProductTierType { get; set; }
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
public bool UseScim { get; set; }
public bool UseGroups { get; set; }
public bool UseDirectory { get; set; }
public bool UseEvents { get; set; }
public bool UseTotp { get; set; }
public bool Use2fa { get; set; }
public bool UseApi { get; set; }
public bool UseResetPassword { get; set; }
public bool UseSecretsManager { get; set; }
public bool UsePasswordManager { get; set; }
public bool UsersGetPremium { get; set; }
public bool UseCustomPermissions { get; set; }
public bool UseActivateAutofillPolicy { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool SelfHost { get; set; }
public int? Seats { get; set; }
public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; }
public string? Key { get; set; }
public bool HasPublicAndPrivateKeys { get; set; }
public bool SsoBound { get; set; }
public bool ResetPasswordEnrolled { get; set; }
public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; }
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public Guid? ProviderId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string? ProviderName { get; set; }
public ProviderType? ProviderType { get; set; }
public bool SsoEnabled { get; set; }
public bool KeyConnectorEnabled { get; set; }
public string? KeyConnectorUrl { get; set; }
public MemberDecryptionType? SsoMemberDecryptionType { get; set; }
public bool AccessSecretsManager { get; set; }
public Guid? UserId { get; set; }
public OrganizationUserStatusType Status { get; set; }
public OrganizationUserType Type { get; set; }
public Permissions? Permissions { get; set; }
}

View File

@@ -1,150 +1,47 @@
// FIXME: Update this file to be null safe and then delete the line below using Bit.Core.Enums;
#nullable disable
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Utilities; using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Response; namespace Bit.Api.AdminConsole.Models.Response;
public class ProfileOrganizationResponseModel : ResponseModel /// <summary>
/// Sync data for organization members and their organization.
/// Note: see <see cref="ProfileProviderOrganizationResponseModel"/> for organization sync data received by provider users.
/// </summary>
public class ProfileOrganizationResponseModel : BaseProfileOrganizationResponseModel
{ {
public ProfileOrganizationResponseModel(string str) : base(str) { }
public ProfileOrganizationResponseModel( public ProfileOrganizationResponseModel(
OrganizationUserOrganizationDetails organization, OrganizationUserOrganizationDetails organizationDetails,
IEnumerable<Guid> organizationIdsClaimingUser) IEnumerable<Guid> organizationIdsClaimingUser)
: this("profileOrganization") : base("profileOrganization", organizationDetails)
{ {
Id = organization.OrganizationId; Status = organizationDetails.Status;
Name = organization.Name; Type = organizationDetails.Type;
UsePolicies = organization.UsePolicies; OrganizationUserId = organizationDetails.OrganizationUserId;
UseSso = organization.UseSso; UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organizationDetails.OrganizationId);
UseKeyConnector = organization.UseKeyConnector; Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organizationDetails.Permissions);
UseScim = organization.UseScim; IsAdminInitiated = organizationDetails.IsAdminInitiated ?? false;
UseGroups = organization.UseGroups; FamilySponsorshipFriendlyName = organizationDetails.FamilySponsorshipFriendlyName;
UseDirectory = organization.UseDirectory; FamilySponsorshipLastSyncDate = organizationDetails.FamilySponsorshipLastSyncDate;
UseEvents = organization.UseEvents; FamilySponsorshipToDelete = organizationDetails.FamilySponsorshipToDelete;
UseTotp = organization.UseTotp; FamilySponsorshipValidUntil = organizationDetails.FamilySponsorshipValidUntil;
Use2fa = organization.Use2fa; FamilySponsorshipAvailable = (organizationDetails.FamilySponsorshipFriendlyName == null || IsAdminInitiated) &&
UseApi = organization.UseApi;
UseResetPassword = organization.UseResetPassword;
UseSecretsManager = organization.UseSecretsManager;
UsePasswordManager = organization.UsePasswordManager;
UsersGetPremium = organization.UsersGetPremium;
UseCustomPermissions = organization.UseCustomPermissions;
UseActivateAutofillPolicy = organization.PlanType.GetProductTier() == ProductTierType.Enterprise;
SelfHost = organization.SelfHost;
Seats = organization.Seats;
MaxCollections = organization.MaxCollections;
MaxStorageGb = organization.MaxStorageGb;
Key = organization.Key;
HasPublicAndPrivateKeys = organization.PublicKey != null && organization.PrivateKey != null;
Status = organization.Status;
Type = organization.Type;
Enabled = organization.Enabled;
SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId);
Identifier = organization.Identifier;
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organization.Permissions);
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organization.ResetPasswordKey);
UserId = organization.UserId;
OrganizationUserId = organization.OrganizationUserId;
ProviderId = organization.ProviderId;
ProviderName = organization.ProviderName;
ProviderType = organization.ProviderType;
FamilySponsorshipFriendlyName = organization.FamilySponsorshipFriendlyName;
IsAdminInitiated = organization.IsAdminInitiated ?? false;
FamilySponsorshipAvailable = (FamilySponsorshipFriendlyName == null || IsAdminInitiated) &&
StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise) StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise)
.UsersCanSponsor(organization); .UsersCanSponsor(organizationDetails);
ProductTierType = organization.PlanType.GetProductTier(); AccessSecretsManager = organizationDetails.AccessSecretsManager;
FamilySponsorshipLastSyncDate = organization.FamilySponsorshipLastSyncDate;
FamilySponsorshipToDelete = organization.FamilySponsorshipToDelete;
FamilySponsorshipValidUntil = organization.FamilySponsorshipValidUntil;
AccessSecretsManager = organization.AccessSecretsManager;
LimitCollectionCreation = organization.LimitCollectionCreation;
LimitCollectionDeletion = organization.LimitCollectionDeletion;
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId);
UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
SsoEnabled = organization.SsoEnabled ?? false;
if (organization.SsoConfig != null)
{
var ssoConfigData = SsoConfigurationData.Deserialize(organization.SsoConfig);
KeyConnectorEnabled = ssoConfigData.MemberDecryptionType == MemberDecryptionType.KeyConnector && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl);
KeyConnectorUrl = ssoConfigData.KeyConnectorUrl;
SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType;
} }
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
}
public Guid Id { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
public bool UseScim { get; set; }
public bool UseGroups { get; set; }
public bool UseDirectory { get; set; }
public bool UseEvents { get; set; }
public bool UseTotp { get; set; }
public bool Use2fa { get; set; }
public bool UseApi { get; set; }
public bool UseResetPassword { get; set; }
public bool UseSecretsManager { get; set; }
public bool UsePasswordManager { get; set; }
public bool UsersGetPremium { get; set; }
public bool UseCustomPermissions { get; set; }
public bool UseActivateAutofillPolicy { get; set; }
public bool SelfHost { get; set; }
public int? Seats { get; set; }
public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; }
public string Key { get; set; }
public OrganizationUserStatusType Status { get; set; }
public OrganizationUserType Type { get; set; }
public bool Enabled { get; set; }
public bool SsoBound { get; set; }
public string Identifier { get; set; }
public Permissions Permissions { get; set; }
public bool ResetPasswordEnrolled { get; set; }
public Guid? UserId { get; set; }
public Guid OrganizationUserId { get; set; } public Guid OrganizationUserId { get; set; }
public bool HasPublicAndPrivateKeys { get; set; } public bool UserIsClaimedByOrganization { get; set; }
public Guid? ProviderId { get; set; } public string? FamilySponsorshipFriendlyName { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; }
public ProviderType? ProviderType { get; set; }
public string FamilySponsorshipFriendlyName { get; set; }
public bool FamilySponsorshipAvailable { get; set; } public bool FamilySponsorshipAvailable { get; set; }
public ProductTierType ProductTierType { get; set; }
public bool KeyConnectorEnabled { get; set; }
public string KeyConnectorUrl { get; set; }
public DateTime? FamilySponsorshipLastSyncDate { get; set; } public DateTime? FamilySponsorshipLastSyncDate { get; set; }
public DateTime? FamilySponsorshipValidUntil { get; set; } public DateTime? FamilySponsorshipValidUntil { get; set; }
public bool? FamilySponsorshipToDelete { get; set; } public bool? FamilySponsorshipToDelete { get; set; }
public bool AccessSecretsManager { get; set; } public bool IsAdminInitiated { get; set; }
public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; }
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
/// <summary> /// <summary>
/// Obsolete. /// Obsolete property for backward compatibility
/// See <see cref="UserIsClaimedByOrganization"/>
/// </summary> /// </summary>
[Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")] [Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")]
public bool UserIsManagedByOrganization public bool UserIsManagedByOrganization
@@ -152,19 +49,4 @@ public class ProfileOrganizationResponseModel : ResponseModel
get => UserIsClaimedByOrganization; get => UserIsClaimedByOrganization;
set => UserIsClaimedByOrganization = value; set => UserIsClaimedByOrganization = value;
} }
/// <summary>
/// Indicates if the user is claimed by the organization.
/// </summary>
/// <remarks>
/// A user is claimed by an organization if the user's email domain is verified by the organization and the user is a member.
/// The organization must be enabled and able to have verified domains.
/// </remarks>
public bool UserIsClaimedByOrganization { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool IsAdminInitiated { get; set; }
public bool SsoEnabled { get; set; }
public MemberDecryptionType? SsoMemberDecryptionType { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
} }

View File

@@ -1,57 +1,24 @@
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
namespace Bit.Api.AdminConsole.Models.Response; namespace Bit.Api.AdminConsole.Models.Response;
public class ProfileProviderOrganizationResponseModel : ProfileOrganizationResponseModel /// <summary>
/// Sync data for provider users and their managed organizations.
/// Note: see <see cref="ProfileOrganizationResponseModel"/> for organization sync data received by organization members.
/// </summary>
public class ProfileProviderOrganizationResponseModel : BaseProfileOrganizationResponseModel
{ {
public ProfileProviderOrganizationResponseModel(ProviderUserOrganizationDetails organization) public ProfileProviderOrganizationResponseModel(ProviderUserOrganizationDetails organizationDetails)
: base("profileProviderOrganization") : base("profileProviderOrganization", organizationDetails)
{ {
Id = organization.OrganizationId;
Name = organization.Name;
UsePolicies = organization.UsePolicies;
UseSso = organization.UseSso;
UseKeyConnector = organization.UseKeyConnector;
UseScim = organization.UseScim;
UseGroups = organization.UseGroups;
UseDirectory = organization.UseDirectory;
UseEvents = organization.UseEvents;
UseTotp = organization.UseTotp;
Use2fa = organization.Use2fa;
UseApi = organization.UseApi;
UseResetPassword = organization.UseResetPassword;
UsersGetPremium = organization.UsersGetPremium;
UseCustomPermissions = organization.UseCustomPermissions;
UseActivateAutofillPolicy = organization.PlanType.GetProductTier() == ProductTierType.Enterprise;
SelfHost = organization.SelfHost;
Seats = organization.Seats;
MaxCollections = organization.MaxCollections;
MaxStorageGb = organization.MaxStorageGb;
Key = organization.Key;
HasPublicAndPrivateKeys = organization.PublicKey != null && organization.PrivateKey != null;
Status = OrganizationUserStatusType.Confirmed; // Provider users are always confirmed Status = OrganizationUserStatusType.Confirmed; // Provider users are always confirmed
Type = OrganizationUserType.Owner; // Provider users behave like Owners Type = OrganizationUserType.Owner; // Provider users behave like Owners
Enabled = organization.Enabled; ProviderId = organizationDetails.ProviderId;
SsoBound = false; ProviderName = organizationDetails.ProviderName;
Identifier = organization.Identifier; ProviderType = organizationDetails.ProviderType;
Permissions = new Permissions(); Permissions = new Permissions();
ResetPasswordEnrolled = false; AccessSecretsManager = false; // Provider users cannot access Secrets Manager
UserId = organization.UserId;
ProviderId = organization.ProviderId;
ProviderName = organization.ProviderName;
ProviderType = organization.ProviderType;
ProductTierType = organization.PlanType.GetProductTier();
LimitCollectionCreation = organization.LimitCollectionCreation;
LimitCollectionDeletion = organization.LimitCollectionDeletion;
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
} }
} }

View File

@@ -0,0 +1,13 @@
using Bit.Api.Utilities;
namespace Bit.Api.Billing.Attributes;
public class NonTokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute
{
private static readonly string[] _acceptedValues = ["accountCredit"];
public NonTokenizedPaymentMethodTypeValidationAttribute() : base(_acceptedValues)
{
ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}";
}
}

View File

@@ -2,11 +2,11 @@
namespace Bit.Api.Billing.Attributes; namespace Bit.Api.Billing.Attributes;
public class PaymentMethodTypeValidationAttribute : StringMatchesAttribute public class TokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute
{ {
private static readonly string[] _acceptedValues = ["bankAccount", "card", "payPal"]; private static readonly string[] _acceptedValues = ["bankAccount", "card", "payPal"];
public PaymentMethodTypeValidationAttribute() : base(_acceptedValues) public TokenizedPaymentMethodTypeValidationAttribute() : base(_acceptedValues)
{ {
ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}"; ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}";
} }

View File

@@ -7,7 +7,7 @@ namespace Bit.Api.Billing.Models.Requests.Payment;
public class MinimalTokenizedPaymentMethodRequest public class MinimalTokenizedPaymentMethodRequest
{ {
[Required] [Required]
[PaymentMethodTypeValidation] [TokenizedPaymentMethodTypeValidation]
public required string Type { get; set; } public required string Type { get; set; }
[Required] [Required]

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Billing.Attributes;
using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.Payment;
public class NonTokenizedPaymentMethodRequest
{
[Required]
[NonTokenizedPaymentMethodTypeValidation]
public required string Type { get; set; }
public NonTokenizedPaymentMethod ToDomain()
{
return Type switch
{
"accountCredit" => new NonTokenizedPaymentMethod { Type = NonTokenizablePaymentMethodType.AccountCredit },
_ => throw new InvalidOperationException($"Invalid value for {nameof(NonTokenizedPaymentMethod)}.{nameof(NonTokenizedPaymentMethod.Type)}")
};
}
}

View File

@@ -4,10 +4,10 @@ using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.Premium; namespace Bit.Api.Billing.Models.Requests.Premium;
public class PremiumCloudHostedSubscriptionRequest public class PremiumCloudHostedSubscriptionRequest : IValidatableObject
{ {
[Required] public MinimalTokenizedPaymentMethodRequest? TokenizedPaymentMethod { get; set; }
public required MinimalTokenizedPaymentMethodRequest TokenizedPaymentMethod { get; set; } public NonTokenizedPaymentMethodRequest? NonTokenizedPaymentMethod { get; set; }
[Required] [Required]
public required MinimalBillingAddressRequest BillingAddress { get; set; } public required MinimalBillingAddressRequest BillingAddress { get; set; }
@@ -15,11 +15,38 @@ public class PremiumCloudHostedSubscriptionRequest
[Range(0, 99)] [Range(0, 99)]
public short AdditionalStorageGb { get; set; } = 0; public short AdditionalStorageGb { get; set; } = 0;
public (TokenizedPaymentMethod, BillingAddress, short) ToDomain()
public (PaymentMethod, BillingAddress, short) ToDomain()
{ {
var paymentMethod = TokenizedPaymentMethod.ToDomain(); // Check if TokenizedPaymentMethod or NonTokenizedPaymentMethod is provided.
var tokenizedPaymentMethod = TokenizedPaymentMethod?.ToDomain();
var nonTokenizedPaymentMethod = NonTokenizedPaymentMethod?.ToDomain();
PaymentMethod paymentMethod = tokenizedPaymentMethod != null
? tokenizedPaymentMethod
: nonTokenizedPaymentMethod!;
var billingAddress = BillingAddress.ToDomain(); var billingAddress = BillingAddress.ToDomain();
return (paymentMethod, billingAddress, AdditionalStorageGb); return (paymentMethod, billingAddress, AdditionalStorageGb);
} }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (TokenizedPaymentMethod == null && NonTokenizedPaymentMethod == null)
{
yield return new ValidationResult(
"Either TokenizedPaymentMethod or NonTokenizedPaymentMethod must be provided.",
new[] { nameof(TokenizedPaymentMethod), nameof(NonTokenizedPaymentMethod) }
);
}
if (TokenizedPaymentMethod != null && NonTokenizedPaymentMethod != null)
{
yield return new ValidationResult(
"Only one of TokenizedPaymentMethod or NonTokenizedPaymentMethod can be provided.",
new[] { nameof(TokenizedPaymentMethod), nameof(NonTokenizedPaymentMethod) }
);
}
}
} }

View File

@@ -1,45 +0,0 @@
using Bit.Api.Models.Request;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Stripe;
namespace Bit.Api.Controllers;
public class MiscController : Controller
{
private readonly BitPayClient _bitPayClient;
private readonly GlobalSettings _globalSettings;
public MiscController(
BitPayClient bitPayClient,
GlobalSettings globalSettings)
{
_bitPayClient = bitPayClient;
_globalSettings = globalSettings;
}
[Authorize("Application")]
[HttpPost("~/bitpay-invoice")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<string> PostBitPayInvoice([FromBody] BitPayInvoiceRequestModel model)
{
var invoice = await _bitPayClient.CreateInvoiceAsync(model.ToBitpayInvoice(_globalSettings));
return invoice.Url;
}
[Authorize("Application")]
[HttpPost("~/setup-payment")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<string> PostSetupPayment()
{
var options = new SetupIntentCreateOptions
{
Usage = "off_session"
};
var service = new SetupIntentService();
var setupIntent = await service.CreateAsync(options);
return setupIntent.ClientSecret;
}
}

View File

@@ -1,73 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Settings;
namespace Bit.Api.Models.Request;
public class BitPayInvoiceRequestModel : IValidatableObject
{
public Guid? UserId { get; set; }
public Guid? OrganizationId { get; set; }
public Guid? ProviderId { get; set; }
public bool Credit { get; set; }
[Required]
public decimal? Amount { get; set; }
public string ReturnUrl { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public BitPayLight.Models.Invoice.Invoice ToBitpayInvoice(GlobalSettings globalSettings)
{
var inv = new BitPayLight.Models.Invoice.Invoice
{
Price = Convert.ToDouble(Amount.Value),
Currency = "USD",
RedirectUrl = ReturnUrl,
Buyer = new BitPayLight.Models.Invoice.Buyer
{
Email = Email,
Name = Name
},
NotificationUrl = globalSettings.BitPay.NotificationUrl,
FullNotifications = true,
ExtendedNotifications = true
};
var posData = string.Empty;
if (UserId.HasValue)
{
posData = "userId:" + UserId.Value;
}
else if (OrganizationId.HasValue)
{
posData = "organizationId:" + OrganizationId.Value;
}
else if (ProviderId.HasValue)
{
posData = "providerId:" + ProviderId.Value;
}
if (Credit)
{
posData += ",accountCredit:1";
inv.ItemDesc = "Bitwarden Account Credit";
}
else
{
inv.ItemDesc = "Bitwarden";
}
inv.PosData = posData;
return inv;
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!UserId.HasValue && !OrganizationId.HasValue && !ProviderId.HasValue)
{
yield return new ValidationResult("User, Organization or Provider is required.");
}
}
}

View File

@@ -94,9 +94,6 @@ public class Startup
services.AddMemoryCache(); services.AddMemoryCache();
services.AddDistributedCache(globalSettings); services.AddDistributedCache(globalSettings);
// BitPay
services.AddSingleton<BitPayClient>();
if (!globalSettings.SelfHosted) if (!globalSettings.SelfHosted)
{ {
services.AddIpRateLimiting(globalSettings); services.AddIpRateLimiting(globalSettings);

View File

@@ -64,7 +64,8 @@
"bitPay": { "bitPay": {
"production": false, "production": false,
"token": "SECRET", "token": "SECRET",
"notificationUrl": "https://bitwarden.com/SECRET" "notificationUrl": "https://bitwarden.com/SECRET",
"webhookKey": "SECRET"
}, },
"amazon": { "amazon": {
"accessKeyId": "SECRET", "accessKeyId": "SECRET",

View File

@@ -8,7 +8,6 @@ public class BillingSettings
public virtual string JobsKey { get; set; } public virtual string JobsKey { get; set; }
public virtual string StripeWebhookKey { get; set; } public virtual string StripeWebhookKey { get; set; }
public virtual string StripeWebhookSecret20250827Basil { get; set; } public virtual string StripeWebhookSecret20250827Basil { get; set; }
public virtual string BitPayWebhookKey { get; set; }
public virtual string AppleWebhookKey { get; set; } public virtual string AppleWebhookKey { get; set; }
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings(); public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();
public virtual string FreshsalesApiKey { get; set; } public virtual string FreshsalesApiKey { get; set; }

View File

@@ -1,7 +0,0 @@
namespace Bit.Billing.Constants;
public static class BitPayInvoiceStatus
{
public const string Confirmed = "confirmed";
public const string Complete = "complete";
}

View File

@@ -1,40 +1,29 @@
// FIXME: Update this file to be null safe and then delete the line below using System.Globalization;
#nullable disable
using System.Globalization;
using Bit.Billing.Constants;
using Bit.Billing.Models; using Bit.Billing.Models;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using BitPayLight.Models.Invoice;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Options;
namespace Bit.Billing.Controllers; namespace Bit.Billing.Controllers;
using static BitPayConstants;
using static StripeConstants;
[Route("bitpay")] [Route("bitpay")]
[ApiExplorerSettings(IgnoreApi = true)] [ApiExplorerSettings(IgnoreApi = true)]
public class BitPayController : Controller public class BitPayController(
{ GlobalSettings globalSettings,
private readonly BillingSettings _billingSettings; IBitPayClient bitPayClient,
private readonly BitPayClient _bitPayClient;
private readonly ITransactionRepository _transactionRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IUserRepository _userRepository;
private readonly IProviderRepository _providerRepository;
private readonly IMailService _mailService;
private readonly IPaymentService _paymentService;
private readonly ILogger<BitPayController> _logger;
private readonly IPremiumUserBillingService _premiumUserBillingService;
public BitPayController(
IOptions<BillingSettings> billingSettings,
BitPayClient bitPayClient,
ITransactionRepository transactionRepository, ITransactionRepository transactionRepository,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IUserRepository userRepository, IUserRepository userRepository,
@@ -43,83 +32,48 @@ public class BitPayController : Controller
IPaymentService paymentService, IPaymentService paymentService,
ILogger<BitPayController> logger, ILogger<BitPayController> logger,
IPremiumUserBillingService premiumUserBillingService) IPremiumUserBillingService premiumUserBillingService)
{ : Controller
_billingSettings = billingSettings?.Value; {
_bitPayClient = bitPayClient;
_transactionRepository = transactionRepository;
_organizationRepository = organizationRepository;
_userRepository = userRepository;
_providerRepository = providerRepository;
_mailService = mailService;
_paymentService = paymentService;
_logger = logger;
_premiumUserBillingService = premiumUserBillingService;
}
[HttpPost("ipn")] [HttpPost("ipn")]
public async Task<IActionResult> PostIpn([FromBody] BitPayEventModel model, [FromQuery] string key) public async Task<IActionResult> PostIpn([FromBody] BitPayEventModel model, [FromQuery] string key)
{ {
if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.BitPayWebhookKey)) if (!CoreHelpers.FixedTimeEquals(key, globalSettings.BitPay.WebhookKey))
{ {
return new BadRequestResult(); return new BadRequestObjectResult("Invalid key");
}
if (model == null || string.IsNullOrWhiteSpace(model.Data?.Id) ||
string.IsNullOrWhiteSpace(model.Event?.Name))
{
return new BadRequestResult();
} }
if (model.Event.Name != BitPayNotificationCode.InvoiceConfirmed) var invoice = await bitPayClient.GetInvoice(model.Data.Id);
{
// Only processing confirmed invoice events for now.
return new OkResult();
}
var invoice = await _bitPayClient.GetInvoiceAsync(model.Data.Id);
if (invoice == null)
{
// Request forged...?
_logger.LogWarning("Invoice not found. #{InvoiceId}", model.Data.Id);
return new BadRequestResult();
}
if (invoice.Status != BitPayInvoiceStatus.Confirmed && invoice.Status != BitPayInvoiceStatus.Complete)
{
_logger.LogWarning("Invoice status of '{InvoiceStatus}' is not acceptable. #{InvoiceId}", invoice.Status, invoice.Id);
return new BadRequestResult();
}
if (invoice.Currency != "USD") if (invoice.Currency != "USD")
{ {
// Only process USD payments logger.LogWarning("Received BitPay invoice webhook for invoice ({InvoiceID}) with non-USD currency: {Currency}", invoice.Id, invoice.Currency);
_logger.LogWarning("Non USD payment received. #{InvoiceId}", invoice.Id); return new BadRequestObjectResult("Cannot process non-USD payments");
return new OkResult();
} }
var (organizationId, userId, providerId) = GetIdsFromPosData(invoice); var (organizationId, userId, providerId) = GetIdsFromPosData(invoice);
if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue) if ((!organizationId.HasValue && !userId.HasValue && !providerId.HasValue) || !invoice.PosData.Contains(PosDataKeys.AccountCredit))
{ {
return new OkResult(); logger.LogWarning("Received BitPay invoice webhook for invoice ({InvoiceID}) that had invalid POS data: {PosData}", invoice.Id, invoice.PosData);
return new BadRequestObjectResult("Invalid POS data");
} }
var isAccountCredit = IsAccountCredit(invoice); if (invoice.Status != InvoiceStatuses.Complete)
if (!isAccountCredit)
{ {
// Only processing credits logger.LogInformation("Received valid BitPay invoice webhook for invoice ({InvoiceID}) that is not yet complete: {Status}",
_logger.LogWarning("Non-credit payment received. #{InvoiceId}", invoice.Id); invoice.Id, invoice.Status);
return new OkResult(); return new OkObjectResult("Waiting for invoice to be completed");
} }
var transaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id); var existingTransaction = await transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id);
if (transaction != null) if (existingTransaction != null)
{ {
_logger.LogWarning("Already processed this invoice. #{InvoiceId}", invoice.Id); logger.LogWarning("Already processed BitPay invoice webhook for invoice ({InvoiceID})", invoice.Id);
return new OkResult(); return new OkObjectResult("Invoice already processed");
} }
try try
{ {
var tx = new Transaction var transaction = new Transaction
{ {
Amount = Convert.ToDecimal(invoice.Price), Amount = Convert.ToDecimal(invoice.Price),
CreationDate = GetTransactionDate(invoice), CreationDate = GetTransactionDate(invoice),
@@ -132,50 +86,47 @@ public class BitPayController : Controller
PaymentMethodType = PaymentMethodType.BitPay, PaymentMethodType = PaymentMethodType.BitPay,
Details = $"{invoice.Currency}, BitPay {invoice.Id}" Details = $"{invoice.Currency}, BitPay {invoice.Id}"
}; };
await _transactionRepository.CreateAsync(tx);
string billingEmail = null; await transactionRepository.CreateAsync(transaction);
if (tx.OrganizationId.HasValue)
var billingEmail = "";
if (transaction.OrganizationId.HasValue)
{ {
var org = await _organizationRepository.GetByIdAsync(tx.OrganizationId.Value); var organization = await organizationRepository.GetByIdAsync(transaction.OrganizationId.Value);
if (org != null) if (organization != null)
{ {
billingEmail = org.BillingEmailAddress(); billingEmail = organization.BillingEmailAddress();
if (await _paymentService.CreditAccountAsync(org, tx.Amount)) if (await paymentService.CreditAccountAsync(organization, transaction.Amount))
{ {
await _organizationRepository.ReplaceAsync(org); await organizationRepository.ReplaceAsync(organization);
} }
} }
} }
else if (tx.UserId.HasValue) else if (transaction.UserId.HasValue)
{ {
var user = await _userRepository.GetByIdAsync(tx.UserId.Value); var user = await userRepository.GetByIdAsync(transaction.UserId.Value);
if (user != null) if (user != null)
{ {
billingEmail = user.BillingEmailAddress(); billingEmail = user.BillingEmailAddress();
await _premiumUserBillingService.Credit(user, tx.Amount); await premiumUserBillingService.Credit(user, transaction.Amount);
} }
} }
else if (tx.ProviderId.HasValue) else if (transaction.ProviderId.HasValue)
{ {
var provider = await _providerRepository.GetByIdAsync(tx.ProviderId.Value); var provider = await providerRepository.GetByIdAsync(transaction.ProviderId.Value);
if (provider != null) if (provider != null)
{ {
billingEmail = provider.BillingEmailAddress(); billingEmail = provider.BillingEmailAddress();
if (await _paymentService.CreditAccountAsync(provider, tx.Amount)) if (await paymentService.CreditAccountAsync(provider, transaction.Amount))
{ {
await _providerRepository.ReplaceAsync(provider); await providerRepository.ReplaceAsync(provider);
} }
} }
} }
else
{
_logger.LogError("Received BitPay account credit transaction that didn't have a user, org, or provider. Invoice#{InvoiceId}", invoice.Id);
}
if (!string.IsNullOrWhiteSpace(billingEmail)) if (!string.IsNullOrWhiteSpace(billingEmail))
{ {
await _mailService.SendAddedCreditAsync(billingEmail, tx.Amount); await mailService.SendAddedCreditAsync(billingEmail, transaction.Amount);
} }
} }
// Catch foreign key violations because user/org could have been deleted. // Catch foreign key violations because user/org could have been deleted.
@@ -186,58 +137,34 @@ public class BitPayController : Controller
return new OkResult(); return new OkResult();
} }
private bool IsAccountCredit(BitPayLight.Models.Invoice.Invoice invoice) private static DateTime GetTransactionDate(Invoice invoice)
{ {
return invoice != null && invoice.PosData != null && invoice.PosData.Contains("accountCredit:1"); var transactions = invoice.Transactions?.Where(transaction =>
transaction.Type == null && !string.IsNullOrWhiteSpace(transaction.Confirmations) &&
transaction.Confirmations != "0").ToList();
return transactions?.Count == 1
? DateTime.Parse(transactions.First().ReceivedTime, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)
: CoreHelpers.FromEpocMilliseconds(invoice.CurrentTime);
} }
private DateTime GetTransactionDate(BitPayLight.Models.Invoice.Invoice invoice) public (Guid? OrganizationId, Guid? UserId, Guid? ProviderId) GetIdsFromPosData(Invoice invoice)
{ {
var transactions = invoice.Transactions?.Where(t => t.Type == null && if (invoice.PosData is null or { Length: 0 } || !invoice.PosData.Contains(':'))
!string.IsNullOrWhiteSpace(t.Confirmations) && t.Confirmations != "0");
if (transactions != null && transactions.Count() == 1)
{ {
return DateTime.Parse(transactions.First().ReceivedTime, CultureInfo.InvariantCulture, return new ValueTuple<Guid?, Guid?, Guid?>(null, null, null);
DateTimeStyles.RoundtripKind);
}
return CoreHelpers.FromEpocMilliseconds(invoice.CurrentTime);
} }
public Tuple<Guid?, Guid?, Guid?> GetIdsFromPosData(BitPayLight.Models.Invoice.Invoice invoice) var ids = invoice.PosData
{ .Split(',')
Guid? orgId = null; .Select(part => part.Split(':'))
Guid? userId = null; .Where(parts => parts.Length == 2 && Guid.TryParse(parts[1], out _))
Guid? providerId = null; .ToDictionary(parts => parts[0], parts => Guid.Parse(parts[1]));
if (invoice == null || string.IsNullOrWhiteSpace(invoice.PosData) || !invoice.PosData.Contains(':')) return new ValueTuple<Guid?, Guid?, Guid?>(
{ ids.TryGetValue(MetadataKeys.OrganizationId, out var id) ? id : null,
return new Tuple<Guid?, Guid?, Guid?>(null, null, null); ids.TryGetValue(MetadataKeys.UserId, out id) ? id : null,
} ids.TryGetValue(MetadataKeys.ProviderId, out id) ? id : null
);
var mainParts = invoice.PosData.Split(',');
foreach (var mainPart in mainParts)
{
var parts = mainPart.Split(':');
if (parts.Length <= 1 || !Guid.TryParse(parts[1], out var id))
{
continue;
}
switch (parts[0])
{
case "userId":
userId = id;
break;
case "organizationId":
orgId = id;
break;
case "providerId":
providerId = id;
break;
}
}
return new Tuple<Guid?, Guid?, Guid?>(orgId, userId, providerId);
} }
} }

View File

@@ -0,0 +1,88 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Quartz;
namespace Bit.Billing.Jobs;
public class ProviderOrganizationDisableJob(
IProviderOrganizationRepository providerOrganizationRepository,
IOrganizationDisableCommand organizationDisableCommand,
ILogger<ProviderOrganizationDisableJob> logger)
: IJob
{
private const int MaxConcurrency = 5;
private const int MaxTimeoutMinutes = 10;
public async Task Execute(IJobExecutionContext context)
{
var providerId = new Guid(context.MergedJobDataMap.GetString("providerId") ?? string.Empty);
var expirationDateString = context.MergedJobDataMap.GetString("expirationDate");
DateTime? expirationDate = string.IsNullOrEmpty(expirationDateString)
? null
: DateTime.Parse(expirationDateString);
logger.LogInformation("Starting to disable organizations for provider {ProviderId}", providerId);
var startTime = DateTime.UtcNow;
var totalProcessed = 0;
var totalErrors = 0;
try
{
var providerOrganizations = await providerOrganizationRepository
.GetManyDetailsByProviderAsync(providerId);
if (providerOrganizations == null || !providerOrganizations.Any())
{
logger.LogInformation("No organizations found for provider {ProviderId}", providerId);
return;
}
logger.LogInformation("Disabling {OrganizationCount} organizations for provider {ProviderId}",
providerOrganizations.Count, providerId);
var semaphore = new SemaphoreSlim(MaxConcurrency, MaxConcurrency);
var tasks = providerOrganizations.Select(async po =>
{
if (DateTime.UtcNow.Subtract(startTime).TotalMinutes > MaxTimeoutMinutes)
{
logger.LogWarning("Timeout reached while disabling organizations for provider {ProviderId}", providerId);
return false;
}
await semaphore.WaitAsync();
try
{
await organizationDisableCommand.DisableAsync(po.OrganizationId, expirationDate);
Interlocked.Increment(ref totalProcessed);
return true;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to disable organization {OrganizationId} for provider {ProviderId}",
po.OrganizationId, providerId);
Interlocked.Increment(ref totalErrors);
return false;
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
logger.LogInformation("Completed disabling organizations for provider {ProviderId}. Processed: {TotalProcessed}, Errors: {TotalErrors}",
providerId, totalProcessed, totalErrors);
}
catch (Exception ex)
{
logger.LogError(ex, "Error disabling organizations for provider {ProviderId}. Processed: {TotalProcessed}, Errors: {TotalErrors}",
providerId, totalProcessed, totalErrors);
throw;
}
}
}

View File

@@ -1,7 +1,11 @@
using Bit.Billing.Constants; using Bit.Billing.Constants;
using Bit.Billing.Jobs;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Services; using Bit.Core.Services;
using Quartz;
using Event = Stripe.Event; using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations; namespace Bit.Billing.Services.Implementations;
@@ -11,17 +15,26 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand;
private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService;
private readonly ISchedulerFactory _schedulerFactory;
public SubscriptionDeletedHandler( public SubscriptionDeletedHandler(
IStripeEventService stripeEventService, IStripeEventService stripeEventService,
IUserService userService, IUserService userService,
IStripeEventUtilityService stripeEventUtilityService, IStripeEventUtilityService stripeEventUtilityService,
IOrganizationDisableCommand organizationDisableCommand) IOrganizationDisableCommand organizationDisableCommand,
IProviderRepository providerRepository,
IProviderService providerService,
ISchedulerFactory schedulerFactory)
{ {
_stripeEventService = stripeEventService; _stripeEventService = stripeEventService;
_userService = userService; _userService = userService;
_stripeEventUtilityService = stripeEventUtilityService; _stripeEventUtilityService = stripeEventUtilityService;
_organizationDisableCommand = organizationDisableCommand; _organizationDisableCommand = organizationDisableCommand;
_providerRepository = providerRepository;
_providerService = providerService;
_schedulerFactory = schedulerFactory;
} }
/// <summary> /// <summary>
@@ -53,9 +66,38 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd()); await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd());
} }
else if (providerId.HasValue)
{
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
if (provider != null)
{
provider.Enabled = false;
await _providerService.UpdateAsync(provider);
await QueueProviderOrganizationDisableJobAsync(providerId.Value, subscription.GetCurrentPeriodEnd());
}
}
else if (userId.HasValue) else if (userId.HasValue)
{ {
await _userService.DisablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd()); await _userService.DisablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd());
} }
} }
private async Task QueueProviderOrganizationDisableJobAsync(Guid providerId, DateTime? expirationDate)
{
var scheduler = await _schedulerFactory.GetScheduler();
var job = JobBuilder.Create<ProviderOrganizationDisableJob>()
.WithIdentity($"disable-provider-orgs-{providerId}", "provider-management")
.UsingJobData("providerId", providerId.ToString())
.UsingJobData("expirationDate", expirationDate?.ToString("O"))
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity($"disable-trigger-{providerId}", "provider-management")
.StartNow()
.Build();
await scheduler.ScheduleJob(job, trigger);
}
} }

View File

@@ -51,9 +51,6 @@ public class Startup
// Repositories // Repositories
services.AddDatabaseRepositories(globalSettings); services.AddDatabaseRepositories(globalSettings);
// BitPay Client
services.AddSingleton<BitPayClient>();
// PayPal IPN Client // PayPal IPN Client
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>(); services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();

View File

@@ -23,7 +23,17 @@ public class IntegrationTemplateContext(EventMessage eventMessage)
public Guid? CollectionId => Event.CollectionId; public Guid? CollectionId => Event.CollectionId;
public Guid? GroupId => Event.GroupId; public Guid? GroupId => Event.GroupId;
public Guid? PolicyId => Event.PolicyId; public Guid? PolicyId => Event.PolicyId;
public Guid? IdempotencyId => Event.IdempotencyId;
public Guid? ProviderId => Event.ProviderId;
public Guid? ProviderUserId => Event.ProviderUserId;
public Guid? ProviderOrganizationId => Event.ProviderOrganizationId;
public Guid? InstallationId => Event.InstallationId;
public Guid? SecretId => Event.SecretId;
public Guid? ProjectId => Event.ProjectId;
public Guid? ServiceAccountId => Event.ServiceAccountId;
public Guid? GrantedServiceAccountId => Event.GrantedServiceAccountId;
public string DateIso8601 => Date.ToString("o");
public string EventMessage => JsonSerializer.Serialize(Event); public string EventMessage => JsonSerializer.Serialize(Event);
public User? User { get; set; } public User? User { get; set; }

View File

@@ -0,0 +1,56 @@
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
namespace Bit.Core.AdminConsole.Models.Data;
/// <summary>
/// Interface defining common organization details properties shared between
/// regular organization users and provider organization users for profile endpoints.
/// </summary>
public interface IProfileOrganizationDetails
{
Guid? UserId { get; set; }
Guid OrganizationId { get; set; }
string Name { get; set; }
bool Enabled { get; set; }
PlanType PlanType { get; set; }
bool UsePolicies { get; set; }
bool UseSso { get; set; }
bool UseKeyConnector { get; set; }
bool UseScim { get; set; }
bool UseGroups { get; set; }
bool UseDirectory { get; set; }
bool UseEvents { get; set; }
bool UseTotp { get; set; }
bool Use2fa { get; set; }
bool UseApi { get; set; }
bool UseResetPassword { get; set; }
bool SelfHost { get; set; }
bool UsersGetPremium { get; set; }
bool UseCustomPermissions { get; set; }
bool UseSecretsManager { get; set; }
int? Seats { get; set; }
short? MaxCollections { get; set; }
short? MaxStorageGb { get; set; }
string? Identifier { get; set; }
string? Key { get; set; }
string? ResetPasswordKey { get; set; }
string? PublicKey { get; set; }
string? PrivateKey { get; set; }
string? SsoExternalId { get; set; }
string? Permissions { get; set; }
Guid? ProviderId { get; set; }
string? ProviderName { get; set; }
ProviderType? ProviderType { get; set; }
bool? SsoEnabled { get; set; }
string? SsoConfig { get; set; }
bool UsePasswordManager { get; set; }
bool LimitCollectionCreation { get; set; }
bool LimitCollectionDeletion { get; set; }
bool AllowAdminAccessToAllCollectionItems { get; set; }
bool UseRiskInsights { get; set; }
bool LimitItemDeletion { get; set; }
bool UseAdminSponsoredFamilies { get; set; }
bool UseOrganizationDomains { get; set; }
bool UseAutomaticUserConfirmation { get; set; }
}

View File

@@ -1,20 +1,18 @@
// FIXME: Update this file to be null safe and then delete the line below using System.Text.Json.Serialization;
#nullable disable
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Utilities; using Bit.Core.Utilities;
namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; namespace Bit.Core.Models.Data.Organizations.OrganizationUsers;
public class OrganizationUserOrganizationDetails public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails
{ {
public Guid OrganizationId { get; set; } public Guid OrganizationId { get; set; }
public Guid? UserId { get; set; } public Guid? UserId { get; set; }
public Guid OrganizationUserId { get; set; } public Guid OrganizationUserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))] [JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; } public string Name { get; set; } = null!;
public bool UsePolicies { get; set; } public bool UsePolicies { get; set; }
public bool UseSso { get; set; } public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; } public bool UseKeyConnector { get; set; }
@@ -33,24 +31,24 @@ public class OrganizationUserOrganizationDetails
public int? Seats { get; set; } public int? Seats { get; set; }
public short? MaxCollections { get; set; } public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; } public short? MaxStorageGb { get; set; }
public string Key { get; set; } public string? Key { get; set; }
public Enums.OrganizationUserStatusType Status { get; set; } public Enums.OrganizationUserStatusType Status { get; set; }
public Enums.OrganizationUserType Type { get; set; } public Enums.OrganizationUserType Type { get; set; }
public bool Enabled { get; set; } public bool Enabled { get; set; }
public PlanType PlanType { get; set; } public PlanType PlanType { get; set; }
public string SsoExternalId { get; set; } public string? SsoExternalId { get; set; }
public string Identifier { get; set; } public string? Identifier { get; set; }
public string Permissions { get; set; } public string? Permissions { get; set; }
public string ResetPasswordKey { get; set; } public string? ResetPasswordKey { get; set; }
public string PublicKey { get; set; } public string? PublicKey { get; set; }
public string PrivateKey { get; set; } public string? PrivateKey { get; set; }
public Guid? ProviderId { get; set; } public Guid? ProviderId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))] [JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; } public string? ProviderName { get; set; }
public ProviderType? ProviderType { get; set; } public ProviderType? ProviderType { get; set; }
public string FamilySponsorshipFriendlyName { get; set; } public string? FamilySponsorshipFriendlyName { get; set; }
public bool? SsoEnabled { get; set; } public bool? SsoEnabled { get; set; }
public string SsoConfig { get; set; } public string? SsoConfig { get; set; }
public DateTime? FamilySponsorshipLastSyncDate { get; set; } public DateTime? FamilySponsorshipLastSyncDate { get; set; }
public DateTime? FamilySponsorshipValidUntil { get; set; } public DateTime? FamilySponsorshipValidUntil { get; set; }
public bool? FamilySponsorshipToDelete { get; set; } public bool? FamilySponsorshipToDelete { get; set; }

View File

@@ -1,19 +1,16 @@
// FIXME: Update this file to be null safe and then delete the line below using System.Text.Json.Serialization;
#nullable disable
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Utilities; using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Models.Data.Provider; namespace Bit.Core.AdminConsole.Models.Data.Provider;
public class ProviderUserOrganizationDetails public class ProviderUserOrganizationDetails : IProfileOrganizationDetails
{ {
public Guid OrganizationId { get; set; } public Guid OrganizationId { get; set; }
public Guid? UserId { get; set; } public Guid? UserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))] [JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; } public string Name { get; set; } = null!;
public bool UsePolicies { get; set; } public bool UsePolicies { get; set; }
public bool UseSso { get; set; } public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; } public bool UseKeyConnector { get; set; }
@@ -28,20 +25,22 @@ public class ProviderUserOrganizationDetails
public bool SelfHost { get; set; } public bool SelfHost { get; set; }
public bool UsersGetPremium { get; set; } public bool UsersGetPremium { get; set; }
public bool UseCustomPermissions { get; set; } public bool UseCustomPermissions { get; set; }
public bool UseSecretsManager { get; set; }
public bool UsePasswordManager { get; set; }
public int? Seats { get; set; } public int? Seats { get; set; }
public short? MaxCollections { get; set; } public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; } public short? MaxStorageGb { get; set; }
public string Key { get; set; } public string? Key { get; set; }
public ProviderUserStatusType Status { get; set; } public ProviderUserStatusType Status { get; set; }
public ProviderUserType Type { get; set; } public ProviderUserType Type { get; set; }
public bool Enabled { get; set; } public bool Enabled { get; set; }
public string Identifier { get; set; } public string? Identifier { get; set; }
public string PublicKey { get; set; } public string? PublicKey { get; set; }
public string PrivateKey { get; set; } public string? PrivateKey { get; set; }
public Guid? ProviderId { get; set; } public Guid? ProviderId { get; set; }
public Guid? ProviderUserId { get; set; } public Guid? ProviderUserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))] [JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; } public string? ProviderName { get; set; }
public PlanType PlanType { get; set; } public PlanType PlanType { get; set; }
public bool LimitCollectionCreation { get; set; } public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; } public bool LimitCollectionDeletion { get; set; }
@@ -50,6 +49,11 @@ public class ProviderUserOrganizationDetails
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; } public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; } public bool UseAdminSponsoredFamilies { get; set; }
public ProviderType ProviderType { get; set; } public ProviderType? ProviderType { get; set; }
public bool UseAutomaticUserConfirmation { get; set; } public bool UseAutomaticUserConfirmation { get; set; }
public bool? SsoEnabled { get; set; }
public string? SsoConfig { get; set; }
public string? SsoExternalId { get; set; }
public string? Permissions { get; set; }
public string? ResetPasswordKey { get; set; }
} }

View File

@@ -65,7 +65,7 @@ public class EmailTokenProvider : IUserTwoFactorTokenProvider<User>
} }
var code = Encoding.UTF8.GetString(cachedValue); var code = Encoding.UTF8.GetString(cachedValue);
var valid = string.Equals(token, code); var valid = CoreHelpers.FixedTimeEquals(token, code);
if (valid) if (valid)
{ {
await _distributedCache.RemoveAsync(cacheKey); await _distributedCache.RemoveAsync(cacheKey);

View File

@@ -64,7 +64,7 @@ public class OtpTokenProvider<TOptions>(
} }
var code = Encoding.UTF8.GetString(cachedValue); var code = Encoding.UTF8.GetString(cachedValue);
var valid = string.Equals(token, code); var valid = CoreHelpers.FixedTimeEquals(token, code);
if (valid) if (valid)
{ {
await _distributedCache.RemoveAsync(cacheKey); await _distributedCache.RemoveAsync(cacheKey);

View File

@@ -0,0 +1,14 @@
namespace Bit.Core.Billing.Constants;
public static class BitPayConstants
{
public static class InvoiceStatuses
{
public const string Complete = "complete";
}
public static class PosDataKeys
{
public const string AccountCredit = "accountCredit:1";
}
}

View File

@@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients; using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Settings; using Bit.Core.Settings;
@@ -9,6 +10,8 @@ using Microsoft.Extensions.Logging;
namespace Bit.Core.Billing.Payment.Commands; namespace Bit.Core.Billing.Payment.Commands;
using static BitPayConstants;
public interface ICreateBitPayInvoiceForCreditCommand public interface ICreateBitPayInvoiceForCreditCommand
{ {
Task<BillingCommandResult<string>> Run( Task<BillingCommandResult<string>> Run(
@@ -31,6 +34,8 @@ public class CreateBitPayInvoiceForCreditCommand(
{ {
var (name, email, posData) = GetSubscriberInformation(subscriber); var (name, email, posData) = GetSubscriberInformation(subscriber);
var notificationUrl = $"{globalSettings.BitPay.NotificationUrl}?key={globalSettings.BitPay.WebhookKey}";
var invoice = new Invoice var invoice = new Invoice
{ {
Buyer = new Buyer { Email = email, Name = name }, Buyer = new Buyer { Email = email, Name = name },
@@ -38,7 +43,7 @@ public class CreateBitPayInvoiceForCreditCommand(
ExtendedNotifications = true, ExtendedNotifications = true,
FullNotifications = true, FullNotifications = true,
ItemDesc = "Bitwarden", ItemDesc = "Bitwarden",
NotificationUrl = globalSettings.BitPay.NotificationUrl, NotificationUrl = notificationUrl,
PosData = posData, PosData = posData,
Price = Convert.ToDouble(amount), Price = Convert.ToDouble(amount),
RedirectUrl = redirectUrl RedirectUrl = redirectUrl
@@ -51,10 +56,10 @@ public class CreateBitPayInvoiceForCreditCommand(
private static (string? Name, string? Email, string POSData) GetSubscriberInformation( private static (string? Name, string? Email, string POSData) GetSubscriberInformation(
ISubscriber subscriber) => subscriber switch ISubscriber subscriber) => subscriber switch
{ {
User user => (user.Email, user.Email, $"userId:{user.Id},accountCredit:1"), User user => (user.Email, user.Email, $"userId:{user.Id},{PosDataKeys.AccountCredit}"),
Organization organization => (organization.Name, organization.BillingEmail, Organization organization => (organization.Name, organization.BillingEmail,
$"organizationId:{organization.Id},accountCredit:1"), $"organizationId:{organization.Id},{PosDataKeys.AccountCredit}"),
Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},accountCredit:1"), Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},{PosDataKeys.AccountCredit}"),
_ => throw new ArgumentOutOfRangeException(nameof(subscriber)) _ => throw new ArgumentOutOfRangeException(nameof(subscriber))
}; };
} }

View File

@@ -0,0 +1,11 @@
namespace Bit.Core.Billing.Payment.Models;
public record NonTokenizedPaymentMethod
{
public NonTokenizablePaymentMethodType Type { get; set; }
}
public enum NonTokenizablePaymentMethodType
{
AccountCredit,
}

View File

@@ -0,0 +1,69 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using OneOf;
namespace Bit.Core.Billing.Payment.Models;
[JsonConverter(typeof(PaymentMethodJsonConverter))]
public class PaymentMethod(OneOf<TokenizedPaymentMethod, NonTokenizedPaymentMethod> input)
: OneOfBase<TokenizedPaymentMethod, NonTokenizedPaymentMethod>(input)
{
public static implicit operator PaymentMethod(TokenizedPaymentMethod tokenized) => new(tokenized);
public static implicit operator PaymentMethod(NonTokenizedPaymentMethod nonTokenized) => new(nonTokenized);
public bool IsTokenized => IsT0;
public bool IsNonTokenized => IsT1;
}
internal class PaymentMethodJsonConverter : JsonConverter<PaymentMethod>
{
public override PaymentMethod Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var element = JsonElement.ParseValue(ref reader);
if (!element.TryGetProperty("type", out var typeProperty))
{
throw new JsonException("PaymentMethod requires a 'type' property");
}
var type = typeProperty.GetString();
if (Enum.TryParse<TokenizablePaymentMethodType>(type, true, out var tokenizedType) &&
Enum.IsDefined(typeof(TokenizablePaymentMethodType), tokenizedType))
{
var token = element.TryGetProperty("token", out var tokenProperty) ? tokenProperty.GetString() : null;
if (string.IsNullOrEmpty(token))
{
throw new JsonException("TokenizedPaymentMethod requires a 'token' property");
}
return new TokenizedPaymentMethod { Type = tokenizedType, Token = token };
}
if (Enum.TryParse<NonTokenizablePaymentMethodType>(type, true, out var nonTokenizedType) &&
Enum.IsDefined(typeof(NonTokenizablePaymentMethodType), nonTokenizedType))
{
return new NonTokenizedPaymentMethod { Type = nonTokenizedType };
}
throw new JsonException($"Unknown payment method type: {type}");
}
public override void Write(Utf8JsonWriter writer, PaymentMethod value, JsonSerializerOptions options)
{
writer.WriteStartObject();
value.Switch(
tokenized =>
{
writer.WriteString("type",
tokenized.Type.ToString().ToLowerInvariant()
);
writer.WriteString("token", tokenized.Token);
},
nonTokenized => { writer.WriteString("type", nonTokenized.Type.ToString().ToLowerInvariant()); }
);
writer.WriteEndObject();
}
}

View File

@@ -16,6 +16,7 @@ using Microsoft.Extensions.Logging;
using OneOf.Types; using OneOf.Types;
using Stripe; using Stripe;
using Customer = Stripe.Customer; using Customer = Stripe.Customer;
using PaymentMethod = Bit.Core.Billing.Payment.Models.PaymentMethod;
using Subscription = Stripe.Subscription; using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Premium.Commands; namespace Bit.Core.Billing.Premium.Commands;
@@ -38,7 +39,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns> /// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
Task<BillingCommandResult<None>> Run( Task<BillingCommandResult<None>> Run(
User user, User user,
TokenizedPaymentMethod paymentMethod, PaymentMethod paymentMethod,
BillingAddress billingAddress, BillingAddress billingAddress,
short additionalStorageGb); short additionalStorageGb);
} }
@@ -60,7 +61,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
public Task<BillingCommandResult<None>> Run( public Task<BillingCommandResult<None>> Run(
User user, User user,
TokenizedPaymentMethod paymentMethod, PaymentMethod paymentMethod,
BillingAddress billingAddress, BillingAddress billingAddress,
short additionalStorageGb) => HandleAsync<None>(async () => short additionalStorageGb) => HandleAsync<None>(async () =>
{ {
@@ -74,6 +75,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
return new BadRequest("Additional storage must be greater than 0."); return new BadRequest("Additional storage must be greater than 0.");
} }
// Note: A customer will already exist if the customer has purchased account credits.
var customer = string.IsNullOrEmpty(user.GatewayCustomerId) var customer = string.IsNullOrEmpty(user.GatewayCustomerId)
? await CreateCustomerAsync(user, paymentMethod, billingAddress) ? await CreateCustomerAsync(user, paymentMethod, billingAddress)
: await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); : await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
@@ -82,7 +84,11 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null); var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null);
switch (paymentMethod) paymentMethod.Switch(
tokenized =>
{
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (tokenized)
{ {
case { Type: TokenizablePaymentMethodType.PayPal } case { Type: TokenizablePaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete: when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete:
@@ -94,6 +100,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
break; break;
} }
} }
},
nonTokenized =>
{
if (subscription.Status == StripeConstants.SubscriptionStatus.Active)
{
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
}
});
user.Gateway = GatewayType.Stripe; user.Gateway = GatewayType.Stripe;
user.GatewayCustomerId = customer.Id; user.GatewayCustomerId = customer.Id;
@@ -109,9 +124,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
}); });
private async Task<Customer> CreateCustomerAsync(User user, private async Task<Customer> CreateCustomerAsync(User user,
TokenizedPaymentMethod paymentMethod, PaymentMethod paymentMethod,
BillingAddress billingAddress) BillingAddress billingAddress)
{ {
if (paymentMethod.IsNonTokenized)
{
_logger.LogError("Cannot create customer for user ({UserID}) using non-tokenized payment method. The customer should already exist", user.Id);
throw new BillingException();
}
var subscriberName = user.SubscriberName(); var subscriberName = user.SubscriberName();
var customerCreateOptions = new CustomerCreateOptions var customerCreateOptions = new CustomerCreateOptions
{ {
@@ -153,13 +174,14 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
var braintreeCustomerId = ""; var braintreeCustomerId = "";
// We have checked that the payment method is tokenized, so we can safely cast it.
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (paymentMethod.Type) switch (paymentMethod.AsT0.Type)
{ {
case TokenizablePaymentMethodType.BankAccount: case TokenizablePaymentMethodType.BankAccount:
{ {
var setupIntent = var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token })) (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.AsT0.Token }))
.FirstOrDefault(); .FirstOrDefault();
if (setupIntent == null) if (setupIntent == null)
@@ -173,19 +195,19 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
} }
case TokenizablePaymentMethodType.Card: case TokenizablePaymentMethodType.Card:
{ {
customerCreateOptions.PaymentMethod = paymentMethod.Token; customerCreateOptions.PaymentMethod = paymentMethod.AsT0.Token;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token; customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.AsT0.Token;
break; break;
} }
case TokenizablePaymentMethodType.PayPal: case TokenizablePaymentMethodType.PayPal:
{ {
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.Token); braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.AsT0.Token);
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break; break;
} }
default: default:
{ {
_logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.Type.ToString()); _logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.AsT0.Type.ToString());
throw new BillingException(); throw new BillingException();
} }
} }
@@ -203,7 +225,9 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
async Task Revert() async Task Revert()
{ {
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (paymentMethod.Type) if (paymentMethod.IsTokenized)
{
switch (paymentMethod.AsT0.Type)
{ {
case TokenizablePaymentMethodType.BankAccount: case TokenizablePaymentMethodType.BankAccount:
{ {
@@ -218,6 +242,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
} }
} }
} }
}
private async Task<Customer> ReconcileBillingLocationAsync( private async Task<Customer> ReconcileBillingLocationAsync(
Customer customer, Customer customer,

View File

@@ -151,6 +151,7 @@ public static class FeatureFlagKeys
public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor";
public const string Otp6Digits = "pm-18612-otp-6-digits"; public const string Otp6Digits = "pm-18612-otp-6-digits";
public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email"; public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email";
public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users";
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword = public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword =
"pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password";
@@ -241,6 +242,7 @@ public static class FeatureFlagKeys
public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view"; public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view";
public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp"; public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp";
public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption"; public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption";
public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium";
/* Innovation Team */ /* Innovation Team */
public const string ArchiveVaultItems = "pm-19148-innovation-archive"; public const string ArchiveVaultItems = "pm-19148-innovation-archive";

View File

@@ -16,7 +16,9 @@
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="licensing.cer" /> <EmbeddedResource Include="licensing.cer" />
<EmbeddedResource Include="licensing_dev.cer" /> <EmbeddedResource Include="licensing_dev.cer" />
<EmbeddedResource Include="MailTemplates\Handlebars\**\*.hbs" />
<!-- Email templates uses .hbs extension, they must be included for emails to work -->
<EmbeddedResource Include="**\*.hbs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -11,12 +11,24 @@ public class OrganizationReport : ITableObject<Guid>
public Guid OrganizationId { get; set; } public Guid OrganizationId { get; set; }
public string ReportData { get; set; } = string.Empty; public string ReportData { get; set; } = string.Empty;
public DateTime CreationDate { get; set; } = DateTime.UtcNow; public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public string ContentEncryptionKey { get; set; } = string.Empty; public string ContentEncryptionKey { get; set; } = string.Empty;
public string? SummaryData { get; set; }
public string? SummaryData { get; set; } = null; public string? ApplicationData { get; set; }
public string? ApplicationData { get; set; } = null;
public DateTime RevisionDate { get; set; } = DateTime.UtcNow; public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
public int? ApplicationCount { get; set; }
public int? ApplicationAtRiskCount { get; set; }
public int? CriticalApplicationCount { get; set; }
public int? CriticalApplicationAtRiskCount { get; set; }
public int? MemberCount { get; set; }
public int? MemberAtRiskCount { get; set; }
public int? CriticalMemberCount { get; set; }
public int? CriticalMemberAtRiskCount { get; set; }
public int? PasswordCount { get; set; }
public int? PasswordAtRiskCount { get; set; }
public int? CriticalPasswordCount { get; set; }
public int? CriticalPasswordAtRiskCount { get; set; }
public void SetNewId() public void SetNewId()
{ {

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@
"prettier": "prettier --cache --write ." "prettier": "prettier --cache --write ."
}, },
"dependencies": { "dependencies": {
"mjml": "4.15.3", "mjml": "4.16.1",
"mjml-core": "4.15.3" "mjml-core": "4.15.3"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,54 @@
namespace Bit.Core.Platform.Mailer;
#nullable enable
/// <summary>
/// BaseMail describes a model for emails. It contains metadata about the email such as recipients,
/// subject, and an optional category for processing at the upstream email delivery service.
///
/// Each BaseMail must have a view model that inherits from BaseMailView. The view model is used to
/// generate the text part and HTML body.
/// </summary>
public abstract class BaseMail<TView> where TView : BaseMailView
{
/// <summary>
/// Email recipients.
/// </summary>
public required IEnumerable<string> ToEmails { get; set; }
/// <summary>
/// The subject of the email.
/// </summary>
public abstract string Subject { get; }
/// <summary>
/// An optional category for processing at the upstream email delivery service.
/// </summary>
public string? Category { get; }
/// <summary>
/// Allows you to override and ignore the suppression list for this email.
///
/// Warning: This should be used with caution, valid reasons are primarily account recovery, email OTP.
/// </summary>
public virtual bool IgnoreSuppressList { get; } = false;
/// <summary>
/// View model for the email body.
/// </summary>
public required TView View { get; set; }
}
/// <summary>
/// Each MailView consists of two body parts: a text part and an HTML part and the filename must be
/// relative to the viewmodel and match the following pattern:
/// - `{ClassName}.html.hbs` for the HTML part
/// - `{ClassName}.text.hbs` for the text part
/// </summary>
public abstract class BaseMailView
{
/// <summary>
/// Current year.
/// </summary>
public string CurrentYear => DateTime.UtcNow.Year.ToString();
}

View File

@@ -0,0 +1,80 @@
#nullable enable
using System.Collections.Concurrent;
using System.Reflection;
using HandlebarsDotNet;
namespace Bit.Core.Platform.Mailer;
public class HandlebarMailRenderer : IMailRenderer
{
/// <summary>
/// Lazy-initialized Handlebars instance. Thread-safe and ensures initialization occurs only once.
/// </summary>
private readonly Lazy<Task<IHandlebars>> _handlebarsTask = new(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>
/// Helper function that returns the handlebar instance.
/// </summary>
private Task<IHandlebars> GetHandlebars() => _handlebarsTask.Value;
/// <summary>
/// This dictionary is used to cache compiled templates in a thread-safe manner.
/// </summary>
private readonly ConcurrentDictionary<string, Lazy<Task<HandlebarsTemplate<object, object>>>> _templateCache = new();
public async Task<(string html, string txt)> RenderAsync(BaseMailView model)
{
var html = await CompileTemplateAsync(model, "html");
var txt = await CompileTemplateAsync(model, "text");
return (html, txt);
}
private async Task<string> CompileTemplateAsync(BaseMailView model, string type)
{
var templateName = $"{model.GetType().FullName}.{type}.hbs";
var assembly = model.GetType().Assembly;
// GetOrAdd is atomic - only one Lazy will be stored per templateName.
// The Lazy with ExecutionAndPublication ensures the compilation happens exactly once.
var lazyTemplate = _templateCache.GetOrAdd(
templateName,
key => new Lazy<Task<HandlebarsTemplate<object, object>>>(
() => CompileTemplateInternalAsync(assembly, key),
LazyThreadSafetyMode.ExecutionAndPublication));
var template = await lazyTemplate.Value;
return template(model);
}
private async Task<HandlebarsTemplate<object, object>> CompileTemplateInternalAsync(Assembly assembly, string templateName)
{
var source = await ReadSourceAsync(assembly, templateName);
var handlebars = await GetHandlebars();
return handlebars.Compile(source);
}
private static async Task<string> ReadSourceAsync(Assembly assembly, string template)
{
if (assembly.GetManifestResourceNames().All(f => f != template))
{
throw new FileNotFoundException("Template not found: " + template);
}
await using var s = assembly.GetManifestResourceStream(template)!;
using var sr = new StreamReader(s);
return await sr.ReadToEndAsync();
}
private static async Task<IHandlebars> InitializeHandlebarsAsync()
{
var handlebars = Handlebars.Create();
// TODO: Do we still need layouts with MJML?
var assembly = typeof(HandlebarMailRenderer).Assembly;
var layoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.Full.html.hbs");
handlebars.RegisterTemplate("FullHtmlLayout", layoutSource);
return handlebars;
}
}

View File

@@ -0,0 +1,7 @@
#nullable enable
namespace Bit.Core.Platform.Mailer;
public interface IMailRenderer
{
Task<(string html, string txt)> RenderAsync(BaseMailView model);
}

View File

@@ -0,0 +1,15 @@
namespace Bit.Core.Platform.Mailer;
#nullable enable
/// <summary>
/// Generic mailer interface for sending email messages.
/// </summary>
public interface IMailer
{
/// <summary>
/// Sends an email message.
/// </summary>
/// <param name="message"></param>
public Task SendEmail<T>(BaseMail<T> message) where T : BaseMailView;
}

View File

@@ -0,0 +1,32 @@
using Bit.Core.Models.Mail;
using Bit.Core.Services;
namespace Bit.Core.Platform.Mailer;
#nullable enable
public class Mailer(IMailRenderer renderer, IMailDeliveryService mailDeliveryService) : IMailer
{
public async Task SendEmail<T>(BaseMail<T> message) where T : BaseMailView
{
var content = await renderer.RenderAsync(message.View);
var metadata = new Dictionary<string, object>();
if (message.IgnoreSuppressList)
{
metadata.Add("SendGridBypassListManagement", true);
}
var mailMessage = new MailMessage
{
ToEmails = message.ToEmails,
Subject = message.Subject,
MetaData = metadata,
HtmlContent = content.html,
TextContent = content.txt,
Category = message.Category,
};
await mailDeliveryService.SendEmailAsync(mailMessage);
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bit.Core.Platform.Mailer;
#nullable enable
/// <summary>
/// Extension methods for adding the Mailer feature to the service collection.
/// </summary>
public static class MailerServiceCollectionExtensions
{
/// <summary>
/// Adds the Mailer services to the <see cref="IServiceCollection"/>.
/// This includes the mail renderer and mailer for sending templated emails.
/// This method is safe to be run multiple times.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
/// <returns>The <see cref="IServiceCollection"/> for additional chaining.</returns>
public static IServiceCollection AddMailer(this IServiceCollection services)
{
services.TryAddSingleton<IMailRenderer, HandlebarMailRenderer>();
services.TryAddSingleton<IMailer, Mailer>();
return services;
}
}

View File

@@ -0,0 +1,200 @@
# Mailer
The Mailer feature provides a structured, type-safe approach to sending emails in the Bitwarden server application. It
uses Handlebars templates to render both HTML and plain text email content.
## Architecture
The Mailer system consists of four main components:
1. **IMailer** - Service interface for sending emails
2. **BaseMail<TView>** - Abstract base class defining email metadata (recipients, subject, category)
3. **BaseMailView** - Abstract base class for email template view models
4. **IMailRenderer** - Internal interface for rendering templates (implemented by `HandlebarMailRenderer`)
## How To Use
1. Define a view model that inherits from `BaseMailView` with properties for template data
2. Create Handlebars templates (`.html.hbs` and `.text.hbs`) as embedded resources, preferably using the MJML pipeline,
`/src/Core/MailTemplates/Mjml`.
3. Define an email class that inherits from `BaseMail<TView>` with metadata like subject
4. Use `IMailer.SendEmail()` to render and send the email
## Creating a New Email
### Step 1: Define the Email & View Model
Create a class that inherits from `BaseMailView`:
```csharp
using Bit.Core.Platform.Mailer;
namespace MyApp.Emails;
public class WelcomeEmailView : BaseMailView
{
public required string UserName { get; init; }
public required string ActivationUrl { get; init; }
}
public class WelcomeEmail : BaseMail<WelcomeEmailView>
{
public override string Subject => "Welcome to Bitwarden";
}
```
### Step 2: Create Handlebars Templates
Create two template files as embedded resources next to your view model. **Important**: The file names must be located
directly next to the `ViewClass` and match the name of the view.
**WelcomeEmailView.html.hbs** (HTML version):
```handlebars
<h1>Welcome, {{ UserName }}!</h1>
<p>Thank you for joining Bitwarden.</p>
<p>
<a href="{{ ActivationUrl }}">Activate your account</a>
</p>
<p><small>&copy; {{ CurrentYear }} Bitwarden Inc.</small></p>
```
**WelcomeEmailView.text.hbs** (plain text version):
```handlebars
Welcome, {{ UserName }}!
Thank you for joining Bitwarden.
Activate your account: {{ ActivationUrl }}
<EFBFBD> {{ CurrentYear }} Bitwarden Inc.
```
**Important**: Template files must be configured as embedded resources in your `.csproj`:
```xml
<ItemGroup>
<EmbeddedResource Include="**\*.hbs" />
</ItemGroup>
```
### Step 3: Send the Email
Inject `IMailer` and send the email, this may be done in a service, command or some other application layer.
```csharp
public class SomeService
{
private readonly IMailer _mailer;
public SomeService(IMailer mailer)
{
_mailer = mailer;
}
public async Task SendWelcomeEmailAsync(string email, string userName, string activationUrl)
{
var mail = new WelcomeEmail
{
ToEmails = [email],
View = new WelcomeEmailView
{
UserName = userName,
ActivationUrl = activationUrl
}
};
await _mailer.SendEmail(mail);
}
}
```
## Advanced Features
### Multiple Recipients
Send to multiple recipients by providing multiple email addresses:
```csharp
var mail = new WelcomeEmail
{
ToEmails = ["user1@example.com", "user2@example.com"],
View = new WelcomeEmailView { /* ... */ }
};
```
### Bypass Suppression List
For critical emails like account recovery or email OTP, you can bypass the suppression list:
```csharp
public class PasswordResetEmail : BaseMail<PasswordResetEmailView>
{
public override string Subject => "Reset Your Password";
public override bool IgnoreSuppressList => true; // Use with caution
}
```
**Warning**: Only use `IgnoreSuppressList = true` for critical account recovery or authentication emails.
### Email Categories
Optionally categorize emails for processing at the upstream email delivery service:
```csharp
public class MarketingEmail : BaseMail<MarketingEmailView>
{
public override string Subject => "Latest Updates";
public string? Category => "marketing";
}
```
## Built-in View Properties
All view models inherit from `BaseMailView`, which provides:
- **CurrentYear** - The current UTC year (useful for copyright notices)
```handlebars
<footer>&copy; {{ CurrentYear }} Bitwarden Inc.</footer>
```
## Template Naming Convention
Templates must follow this naming convention:
- HTML template: `{ViewModelFullName}.html.hbs`
- Text template: `{ViewModelFullName}.text.hbs`
For example, if your view model is `Bit.Core.Auth.Models.Mail.VerifyEmailView`, the templates must be:
- `Bit.Core.Auth.Models.Mail.VerifyEmailView.html.hbs`
- `Bit.Core.Auth.Models.Mail.VerifyEmailView.text.hbs`
## Dependency Injection
Register the Mailer services in your DI container using the extension method:
```csharp
using Bit.Core.Platform.Mailer;
services.AddMailer();
```
Or manually register the services:
```csharp
using Microsoft.Extensions.DependencyInjection.Extensions;
services.TryAddSingleton<IMailRenderer, HandlebarMailRenderer>();
services.TryAddSingleton<IMailer, Mailer>();
```
## Performance Notes
- **Template caching** - `HandlebarMailRenderer` automatically caches compiled templates
- **Lazy initialization** - Handlebars is initialized only when first needed
- **Thread-safe** - The renderer is thread-safe for concurrent email rendering

View File

@@ -508,9 +508,15 @@
<data name="UserIdAndTokenMismatch" xml:space="preserve"> <data name="UserIdAndTokenMismatch" xml:space="preserve">
<value>Supplied userId and token did not match.</value> <value>Supplied userId and token did not match.</value>
</data> </data>
<data name="UserShouldBeFound" xml:space="preserve">
<value>User should have been defined by this point.</value>
</data>
<data name="CouldNotFindOrganization" xml:space="preserve"> <data name="CouldNotFindOrganization" xml:space="preserve">
<value>Could not find organization for '{0}'</value> <value>Could not find organization for '{0}'</value>
</data> </data>
<data name="CouldNotFindOrganizationUser" xml:space="preserve">
<value>Could not find organization user for user '{0}' organization '{1}'</value>
</data>
<data name="NoSeatsAvailable" xml:space="preserve"> <data name="NoSeatsAvailable" xml:space="preserve">
<value>No seats available for organization, '{0}'</value> <value>No seats available for organization, '{0}'</value>
</data> </data>

View File

@@ -677,6 +677,7 @@ public class GlobalSettings : IGlobalSettings
public bool Production { get; set; } public bool Production { get; set; }
public string Token { get; set; } public string Token { get; set; }
public string NotificationUrl { get; set; } public string NotificationUrl { get; set; }
public string WebhookKey { get; set; }
} }
public class InstallationSettings : IInstallationSettings public class InstallationSettings : IInstallationSettings

View File

@@ -1,30 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Settings;
namespace Bit.Core.Utilities;
public class BitPayClient
{
private readonly BitPayLight.BitPay _bpClient;
public BitPayClient(GlobalSettings globalSettings)
{
if (CoreHelpers.SettingHasValue(globalSettings.BitPay.Token))
{
_bpClient = new BitPayLight.BitPay(globalSettings.BitPay.Token,
globalSettings.BitPay.Production ? BitPayLight.Env.Prod : BitPayLight.Env.Test);
}
}
public Task<BitPayLight.Models.Invoice.Invoice> GetInvoiceAsync(string id)
{
return _bpClient.GetInvoice(id);
}
public Task<BitPayLight.Models.Invoice.Invoice> CreateInvoiceAsync(BitPayLight.Models.Invoice.Invoice invoice)
{
return _bpClient.CreateInvoice(invoice);
}
}

View File

@@ -73,7 +73,8 @@ public class OrganizationUserOrganizationDetailsViewQuery : IQuery<OrganizationU
UseAdminSponsoredFamilies = o.UseAdminSponsoredFamilies, UseAdminSponsoredFamilies = o.UseAdminSponsoredFamilies,
LimitItemDeletion = o.LimitItemDeletion, LimitItemDeletion = o.LimitItemDeletion,
IsAdminInitiated = os.IsAdminInitiated, IsAdminInitiated = os.IsAdminInitiated,
UseOrganizationDomains = o.UseOrganizationDomains UseOrganizationDomains = o.UseOrganizationDomains,
UseAutomaticUserConfirmation = o.UseAutomaticUserConfirmation
}; };
return query; return query;
} }

View File

@@ -12,7 +12,9 @@ public class ProviderUserOrganizationDetailsViewQuery : IQuery<ProviderUserOrgan
join po in dbContext.ProviderOrganizations on pu.ProviderId equals po.ProviderId join po in dbContext.ProviderOrganizations on pu.ProviderId equals po.ProviderId
join o in dbContext.Organizations on po.OrganizationId equals o.Id join o in dbContext.Organizations on po.OrganizationId equals o.Id
join p in dbContext.Providers on pu.ProviderId equals p.Id join p in dbContext.Providers on pu.ProviderId equals p.Id
select new { pu, po, o, p }; join ss in dbContext.SsoConfigs on o.Id equals ss.OrganizationId into ss_g
from ss in ss_g.DefaultIfEmpty()
select new { pu, po, o, p, ss };
return query.Select(x => new ProviderUserOrganizationDetails return query.Select(x => new ProviderUserOrganizationDetails
{ {
OrganizationId = x.po.OrganizationId, OrganizationId = x.po.OrganizationId,
@@ -29,6 +31,9 @@ public class ProviderUserOrganizationDetailsViewQuery : IQuery<ProviderUserOrgan
UseTotp = x.o.UseTotp, UseTotp = x.o.UseTotp,
Use2fa = x.o.Use2fa, Use2fa = x.o.Use2fa,
UseApi = x.o.UseApi, UseApi = x.o.UseApi,
UseResetPassword = x.o.UseResetPassword,
UseSecretsManager = x.o.UseSecretsManager,
UsePasswordManager = x.o.UsePasswordManager,
SelfHost = x.o.SelfHost, SelfHost = x.o.SelfHost,
UsersGetPremium = x.o.UsersGetPremium, UsersGetPremium = x.o.UsersGetPremium,
UseCustomPermissions = x.o.UseCustomPermissions, UseCustomPermissions = x.o.UseCustomPermissions,
@@ -39,6 +44,7 @@ public class ProviderUserOrganizationDetailsViewQuery : IQuery<ProviderUserOrgan
Key = x.po.Key, Key = x.po.Key,
Status = x.pu.Status, Status = x.pu.Status,
Type = x.pu.Type, Type = x.pu.Type,
ProviderUserId = x.pu.Id,
PublicKey = x.o.PublicKey, PublicKey = x.o.PublicKey,
PrivateKey = x.o.PrivateKey, PrivateKey = x.o.PrivateKey,
ProviderId = x.p.Id, ProviderId = x.p.Id,
@@ -52,7 +58,9 @@ public class ProviderUserOrganizationDetailsViewQuery : IQuery<ProviderUserOrgan
ProviderType = x.p.Type, ProviderType = x.p.Type,
UseOrganizationDomains = x.o.UseOrganizationDomains, UseOrganizationDomains = x.o.UseOrganizationDomains,
UseAdminSponsoredFamilies = x.o.UseAdminSponsoredFamilies, UseAdminSponsoredFamilies = x.o.UseAdminSponsoredFamilies,
UseAutomaticUserConfirmation = x.o.UseAutomaticUserConfirmation UseAutomaticUserConfirmation = x.o.UseAutomaticUserConfirmation,
SsoEnabled = x.ss.Enabled,
SsoConfig = x.ss.Data,
}); });
} }
} }

View File

@@ -38,6 +38,7 @@ using Bit.Core.KeyManagement;
using Bit.Core.NotificationCenter; using Bit.Core.NotificationCenter;
using Bit.Core.OrganizationFeatures; using Bit.Core.OrganizationFeatures;
using Bit.Core.Platform; using Bit.Core.Platform;
using Bit.Core.Platform.Mailer;
using Bit.Core.Platform.Push; using Bit.Core.Platform.Push;
using Bit.Core.Platform.PushRegistration.Internal; using Bit.Core.Platform.PushRegistration.Internal;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@@ -242,8 +243,11 @@ public static class ServiceCollectionExtensions
services.AddScoped<IPaymentService, StripePaymentService>(); services.AddScoped<IPaymentService, StripePaymentService>();
services.AddScoped<IPaymentHistoryService, PaymentHistoryService>(); services.AddScoped<IPaymentHistoryService, PaymentHistoryService>();
services.AddScoped<ITwoFactorEmailService, TwoFactorEmailService>(); services.AddScoped<ITwoFactorEmailService, TwoFactorEmailService>();
// Legacy mailer service
services.AddSingleton<IStripeSyncService, StripeSyncService>(); services.AddSingleton<IStripeSyncService, StripeSyncService>();
services.AddSingleton<IMailService, HandlebarsMailService>(); services.AddSingleton<IMailService, HandlebarsMailService>();
// Modern mailers
services.AddMailer();
services.AddSingleton<ILicensingService, LicensingService>(); services.AddSingleton<ILicensingService, LicensingService>();
services.AddSingleton<ILookupClient>(_ => services.AddSingleton<ILookupClient>(_ =>
{ {

View File

@@ -6,7 +6,19 @@ CREATE PROCEDURE [dbo].[OrganizationReport_Create]
@ContentEncryptionKey VARCHAR(MAX), @ContentEncryptionKey VARCHAR(MAX),
@SummaryData NVARCHAR(MAX), @SummaryData NVARCHAR(MAX),
@ApplicationData NVARCHAR(MAX), @ApplicationData NVARCHAR(MAX),
@RevisionDate DATETIME2(7) @RevisionDate DATETIME2(7),
@ApplicationCount INT = NULL,
@ApplicationAtRiskCount INT = NULL,
@CriticalApplicationCount INT = NULL,
@CriticalApplicationAtRiskCount INT = NULL,
@MemberCount INT = NULL,
@MemberAtRiskCount INT = NULL,
@CriticalMemberCount INT = NULL,
@CriticalMemberAtRiskCount INT = NULL,
@PasswordCount INT = NULL,
@PasswordAtRiskCount INT = NULL,
@CriticalPasswordCount INT = NULL,
@CriticalPasswordAtRiskCount INT = NULL
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET NOCOUNT ON;
@@ -20,7 +32,19 @@ INSERT INTO [dbo].[OrganizationReport](
[ContentEncryptionKey], [ContentEncryptionKey],
[SummaryData], [SummaryData],
[ApplicationData], [ApplicationData],
[RevisionDate] [RevisionDate],
[ApplicationCount],
[ApplicationAtRiskCount],
[CriticalApplicationCount],
[CriticalApplicationAtRiskCount],
[MemberCount],
[MemberAtRiskCount],
[CriticalMemberCount],
[CriticalMemberAtRiskCount],
[PasswordCount],
[PasswordAtRiskCount],
[CriticalPasswordCount],
[CriticalPasswordAtRiskCount]
) )
VALUES ( VALUES (
@Id, @Id,
@@ -30,6 +54,18 @@ VALUES (
@ContentEncryptionKey, @ContentEncryptionKey,
@SummaryData, @SummaryData,
@ApplicationData, @ApplicationData,
@RevisionDate @RevisionDate,
@ApplicationCount,
@ApplicationAtRiskCount,
@CriticalApplicationCount,
@CriticalApplicationAtRiskCount,
@MemberCount,
@MemberAtRiskCount,
@CriticalMemberCount,
@CriticalMemberAtRiskCount,
@PasswordCount,
@PasswordAtRiskCount,
@CriticalPasswordCount,
@CriticalPasswordAtRiskCount
); );
END END

View File

@@ -5,14 +5,7 @@ BEGIN
SET NOCOUNT ON SET NOCOUNT ON
SELECT TOP 1 SELECT TOP 1
[Id], *
[OrganizationId],
[ReportData],
[CreationDate],
[ContentEncryptionKey],
[SummaryData],
[ApplicationData],
[RevisionDate]
FROM [dbo].[OrganizationReportView] FROM [dbo].[OrganizationReportView]
WHERE [OrganizationId] = @OrganizationId WHERE [OrganizationId] = @OrganizationId
ORDER BY [RevisionDate] DESC ORDER BY [RevisionDate] DESC

View File

@@ -6,7 +6,19 @@ CREATE PROCEDURE [dbo].[OrganizationReport_Update]
@ContentEncryptionKey VARCHAR(MAX), @ContentEncryptionKey VARCHAR(MAX),
@SummaryData NVARCHAR(MAX), @SummaryData NVARCHAR(MAX),
@ApplicationData NVARCHAR(MAX), @ApplicationData NVARCHAR(MAX),
@RevisionDate DATETIME2(7) @RevisionDate DATETIME2(7),
@ApplicationCount INT = NULL,
@ApplicationAtRiskCount INT = NULL,
@CriticalApplicationCount INT = NULL,
@CriticalApplicationAtRiskCount INT = NULL,
@MemberCount INT = NULL,
@MemberAtRiskCount INT = NULL,
@CriticalMemberCount INT = NULL,
@CriticalMemberAtRiskCount INT = NULL,
@PasswordCount INT = NULL,
@PasswordAtRiskCount INT = NULL,
@CriticalPasswordCount INT = NULL,
@CriticalPasswordAtRiskCount INT = NULL
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET NOCOUNT ON;
@@ -18,6 +30,18 @@ BEGIN
[ContentEncryptionKey] = @ContentEncryptionKey, [ContentEncryptionKey] = @ContentEncryptionKey,
[SummaryData] = @SummaryData, [SummaryData] = @SummaryData,
[ApplicationData] = @ApplicationData, [ApplicationData] = @ApplicationData,
[RevisionDate] = @RevisionDate [RevisionDate] = @RevisionDate,
[ApplicationCount] = @ApplicationCount,
[ApplicationAtRiskCount] = @ApplicationAtRiskCount,
[CriticalApplicationCount] = @CriticalApplicationCount,
[CriticalApplicationAtRiskCount] = @CriticalApplicationAtRiskCount,
[MemberCount] = @MemberCount,
[MemberAtRiskCount] = @MemberAtRiskCount,
[CriticalMemberCount] = @CriticalMemberCount,
[CriticalMemberAtRiskCount] = @CriticalMemberAtRiskCount,
[PasswordCount] = @PasswordCount,
[PasswordAtRiskCount] = @PasswordAtRiskCount,
[CriticalPasswordCount] = @CriticalPasswordCount,
[CriticalPasswordAtRiskCount] = @CriticalPasswordAtRiskCount
WHERE [Id] = @Id; WHERE [Id] = @Id;
END; END;

View File

@@ -7,6 +7,18 @@ CREATE TABLE [dbo].[OrganizationReport] (
[SummaryData] NVARCHAR(MAX) NULL, [SummaryData] NVARCHAR(MAX) NULL,
[ApplicationData] NVARCHAR(MAX) NULL, [ApplicationData] NVARCHAR(MAX) NULL,
[RevisionDate] DATETIME2 (7) NULL, [RevisionDate] DATETIME2 (7) NULL,
[ApplicationCount] INT NULL,
[ApplicationAtRiskCount] INT NULL,
[CriticalApplicationCount] INT NULL,
[CriticalApplicationAtRiskCount] INT NULL,
[MemberCount] INT NULL,
[MemberAtRiskCount] INT NULL,
[CriticalMemberCount] INT NULL,
[CriticalMemberAtRiskCount] INT NULL,
[PasswordCount] INT NULL,
[PasswordAtRiskCount] INT NULL,
[CriticalPasswordCount] INT NULL,
[CriticalPasswordAtRiskCount] INT NULL,
CONSTRAINT [PK_OrganizationReport] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [PK_OrganizationReport] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_OrganizationReport_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) CONSTRAINT [FK_OrganizationReport_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id])
); );

View File

@@ -16,6 +16,8 @@ SELECT
O.[Use2fa], O.[Use2fa],
O.[UseApi], O.[UseApi],
O.[UseResetPassword], O.[UseResetPassword],
O.[UseSecretsManager],
O.[UsePasswordManager],
O.[SelfHost], O.[SelfHost],
O.[UsersGetPremium], O.[UsersGetPremium],
O.[UseCustomPermissions], O.[UseCustomPermissions],
@@ -40,7 +42,9 @@ SELECT
P.[Type] ProviderType, P.[Type] ProviderType,
O.[LimitItemDeletion], O.[LimitItemDeletion],
O.[UseOrganizationDomains], O.[UseOrganizationDomains],
O.[UseAutomaticUserConfirmation] O.[UseAutomaticUserConfirmation],
SS.[Enabled] SsoEnabled,
SS.[Data] SsoConfig
FROM FROM
[dbo].[ProviderUser] PU [dbo].[ProviderUser] PU
INNER JOIN INNER JOIN
@@ -49,3 +53,5 @@ INNER JOIN
[dbo].[Organization] O ON O.[Id] = PO.[OrganizationId] [dbo].[Organization] O ON O.[Id] = PO.[OrganizationId]
INNER JOIN INNER JOIN
[dbo].[Provider] P ON P.[Id] = PU.[ProviderId] [dbo].[Provider] P ON P.[Id] = PU.[ProviderId]
LEFT JOIN
[dbo].[SsoConfig] SS ON SS.[OrganizationId] = O.[Id]

View File

@@ -0,0 +1,150 @@
using Bit.Api.AdminConsole.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Models.Response;
public class ProfileOrganizationResponseModelTests
{
[Theory, BitAutoData]
public void Constructor_ShouldPopulatePropertiesCorrectly(Organization organization)
{
var userId = Guid.NewGuid();
var organizationUserId = Guid.NewGuid();
var providerId = Guid.NewGuid();
var organizationIdsClaimingUser = new[] { organization.Id };
var organizationDetails = new OrganizationUserOrganizationDetails
{
OrganizationId = organization.Id,
UserId = userId,
OrganizationUserId = organizationUserId,
Name = organization.Name,
Enabled = organization.Enabled,
Identifier = organization.Identifier,
PlanType = organization.PlanType,
UsePolicies = organization.UsePolicies,
UseSso = organization.UseSso,
UseKeyConnector = organization.UseKeyConnector,
UseScim = organization.UseScim,
UseGroups = organization.UseGroups,
UseDirectory = organization.UseDirectory,
UseEvents = organization.UseEvents,
UseTotp = organization.UseTotp,
Use2fa = organization.Use2fa,
UseApi = organization.UseApi,
UseResetPassword = organization.UseResetPassword,
UseSecretsManager = organization.UseSecretsManager,
UsePasswordManager = organization.UsePasswordManager,
UsersGetPremium = organization.UsersGetPremium,
UseCustomPermissions = organization.UseCustomPermissions,
UseRiskInsights = organization.UseRiskInsights,
UseOrganizationDomains = organization.UseOrganizationDomains,
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies,
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation,
SelfHost = organization.SelfHost,
Seats = organization.Seats,
MaxCollections = organization.MaxCollections,
MaxStorageGb = organization.MaxStorageGb,
Key = "organization-key",
PublicKey = "public-key",
PrivateKey = "private-key",
LimitCollectionCreation = organization.LimitCollectionCreation,
LimitCollectionDeletion = organization.LimitCollectionDeletion,
LimitItemDeletion = organization.LimitItemDeletion,
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems,
ProviderId = providerId,
ProviderName = "Test Provider",
ProviderType = ProviderType.Msp,
SsoEnabled = true,
SsoConfig = new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.KeyConnector,
KeyConnectorUrl = "https://keyconnector.example.com"
}.Serialize(),
SsoExternalId = "external-sso-id",
Permissions = CoreHelpers.ClassToJsonData(new Core.Models.Data.Permissions { ManageUsers = true }),
ResetPasswordKey = "reset-password-key",
FamilySponsorshipFriendlyName = "Family Sponsorship",
FamilySponsorshipLastSyncDate = DateTime.UtcNow.AddDays(-1),
FamilySponsorshipToDelete = false,
FamilySponsorshipValidUntil = DateTime.UtcNow.AddYears(1),
IsAdminInitiated = true,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Owner,
AccessSecretsManager = true,
SmSeats = 5,
SmServiceAccounts = 10
};
var result = new ProfileOrganizationResponseModel(organizationDetails, organizationIdsClaimingUser);
Assert.Equal("profileOrganization", result.Object);
Assert.Equal(organization.Id, result.Id);
Assert.Equal(userId, result.UserId);
Assert.Equal(organization.Name, result.Name);
Assert.Equal(organization.Enabled, result.Enabled);
Assert.Equal(organization.Identifier, result.Identifier);
Assert.Equal(organization.PlanType.GetProductTier(), result.ProductTierType);
Assert.Equal(organization.UsePolicies, result.UsePolicies);
Assert.Equal(organization.UseSso, result.UseSso);
Assert.Equal(organization.UseKeyConnector, result.UseKeyConnector);
Assert.Equal(organization.UseScim, result.UseScim);
Assert.Equal(organization.UseGroups, result.UseGroups);
Assert.Equal(organization.UseDirectory, result.UseDirectory);
Assert.Equal(organization.UseEvents, result.UseEvents);
Assert.Equal(organization.UseTotp, result.UseTotp);
Assert.Equal(organization.Use2fa, result.Use2fa);
Assert.Equal(organization.UseApi, result.UseApi);
Assert.Equal(organization.UseResetPassword, result.UseResetPassword);
Assert.Equal(organization.UseSecretsManager, result.UseSecretsManager);
Assert.Equal(organization.UsePasswordManager, result.UsePasswordManager);
Assert.Equal(organization.UsersGetPremium, result.UsersGetPremium);
Assert.Equal(organization.UseCustomPermissions, result.UseCustomPermissions);
Assert.Equal(organization.PlanType.GetProductTier() == ProductTierType.Enterprise, result.UseActivateAutofillPolicy);
Assert.Equal(organization.UseRiskInsights, result.UseRiskInsights);
Assert.Equal(organization.UseOrganizationDomains, result.UseOrganizationDomains);
Assert.Equal(organization.UseAdminSponsoredFamilies, result.UseAdminSponsoredFamilies);
Assert.Equal(organization.UseAutomaticUserConfirmation, result.UseAutomaticUserConfirmation);
Assert.Equal(organization.SelfHost, result.SelfHost);
Assert.Equal(organization.Seats, result.Seats);
Assert.Equal(organization.MaxCollections, result.MaxCollections);
Assert.Equal(organization.MaxStorageGb, result.MaxStorageGb);
Assert.Equal(organizationDetails.Key, result.Key);
Assert.True(result.HasPublicAndPrivateKeys);
Assert.Equal(organization.LimitCollectionCreation, result.LimitCollectionCreation);
Assert.Equal(organization.LimitCollectionDeletion, result.LimitCollectionDeletion);
Assert.Equal(organization.LimitItemDeletion, result.LimitItemDeletion);
Assert.Equal(organization.AllowAdminAccessToAllCollectionItems, result.AllowAdminAccessToAllCollectionItems);
Assert.Equal(organizationDetails.ProviderId, result.ProviderId);
Assert.Equal(organizationDetails.ProviderName, result.ProviderName);
Assert.Equal(organizationDetails.ProviderType, result.ProviderType);
Assert.Equal(organizationDetails.SsoEnabled, result.SsoEnabled);
Assert.True(result.KeyConnectorEnabled);
Assert.Equal("https://keyconnector.example.com", result.KeyConnectorUrl);
Assert.Equal(MemberDecryptionType.KeyConnector, result.SsoMemberDecryptionType);
Assert.True(result.SsoBound);
Assert.Equal(organizationDetails.Status, result.Status);
Assert.Equal(organizationDetails.Type, result.Type);
Assert.Equal(organizationDetails.OrganizationUserId, result.OrganizationUserId);
Assert.True(result.UserIsClaimedByOrganization);
Assert.NotNull(result.Permissions);
Assert.True(result.ResetPasswordEnrolled);
Assert.Equal(organizationDetails.AccessSecretsManager, result.AccessSecretsManager);
Assert.Equal(organizationDetails.FamilySponsorshipFriendlyName, result.FamilySponsorshipFriendlyName);
Assert.Equal(organizationDetails.FamilySponsorshipLastSyncDate, result.FamilySponsorshipLastSyncDate);
Assert.Equal(organizationDetails.FamilySponsorshipToDelete, result.FamilySponsorshipToDelete);
Assert.Equal(organizationDetails.FamilySponsorshipValidUntil, result.FamilySponsorshipValidUntil);
Assert.True(result.IsAdminInitiated);
Assert.False(result.FamilySponsorshipAvailable);
}
}

View File

@@ -0,0 +1,129 @@
using Bit.Api.AdminConsole.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Models.Response;
public class ProfileProviderOrganizationResponseModelTests
{
[Theory, BitAutoData]
public void Constructor_ShouldPopulatePropertiesCorrectly(Organization organization)
{
var userId = Guid.NewGuid();
var providerId = Guid.NewGuid();
var providerUserId = Guid.NewGuid();
var organizationDetails = new ProviderUserOrganizationDetails
{
OrganizationId = organization.Id,
UserId = userId,
Name = organization.Name,
Enabled = organization.Enabled,
Identifier = organization.Identifier,
PlanType = organization.PlanType,
UsePolicies = organization.UsePolicies,
UseSso = organization.UseSso,
UseKeyConnector = organization.UseKeyConnector,
UseScim = organization.UseScim,
UseGroups = organization.UseGroups,
UseDirectory = organization.UseDirectory,
UseEvents = organization.UseEvents,
UseTotp = organization.UseTotp,
Use2fa = organization.Use2fa,
UseApi = organization.UseApi,
UseResetPassword = organization.UseResetPassword,
UseSecretsManager = organization.UseSecretsManager,
UsePasswordManager = organization.UsePasswordManager,
UsersGetPremium = organization.UsersGetPremium,
UseCustomPermissions = organization.UseCustomPermissions,
UseRiskInsights = organization.UseRiskInsights,
UseOrganizationDomains = organization.UseOrganizationDomains,
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies,
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation,
SelfHost = organization.SelfHost,
Seats = organization.Seats,
MaxCollections = organization.MaxCollections,
MaxStorageGb = organization.MaxStorageGb,
Key = "provider-org-key",
PublicKey = "public-key",
PrivateKey = "private-key",
LimitCollectionCreation = organization.LimitCollectionCreation,
LimitCollectionDeletion = organization.LimitCollectionDeletion,
LimitItemDeletion = organization.LimitItemDeletion,
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems,
ProviderId = providerId,
ProviderName = "Test MSP Provider",
ProviderType = ProviderType.Msp,
SsoEnabled = true,
SsoConfig = new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption
}.Serialize(),
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ProviderAdmin,
ProviderUserId = providerUserId
};
var result = new ProfileProviderOrganizationResponseModel(organizationDetails);
Assert.Equal("profileProviderOrganization", result.Object);
Assert.Equal(organization.Id, result.Id);
Assert.Equal(userId, result.UserId);
Assert.Equal(organization.Name, result.Name);
Assert.Equal(organization.Enabled, result.Enabled);
Assert.Equal(organization.Identifier, result.Identifier);
Assert.Equal(organization.PlanType.GetProductTier(), result.ProductTierType);
Assert.Equal(organization.UsePolicies, result.UsePolicies);
Assert.Equal(organization.UseSso, result.UseSso);
Assert.Equal(organization.UseKeyConnector, result.UseKeyConnector);
Assert.Equal(organization.UseScim, result.UseScim);
Assert.Equal(organization.UseGroups, result.UseGroups);
Assert.Equal(organization.UseDirectory, result.UseDirectory);
Assert.Equal(organization.UseEvents, result.UseEvents);
Assert.Equal(organization.UseTotp, result.UseTotp);
Assert.Equal(organization.Use2fa, result.Use2fa);
Assert.Equal(organization.UseApi, result.UseApi);
Assert.Equal(organization.UseResetPassword, result.UseResetPassword);
Assert.Equal(organization.UseSecretsManager, result.UseSecretsManager);
Assert.Equal(organization.UsePasswordManager, result.UsePasswordManager);
Assert.Equal(organization.UsersGetPremium, result.UsersGetPremium);
Assert.Equal(organization.UseCustomPermissions, result.UseCustomPermissions);
Assert.Equal(organization.PlanType.GetProductTier() == ProductTierType.Enterprise, result.UseActivateAutofillPolicy);
Assert.Equal(organization.UseRiskInsights, result.UseRiskInsights);
Assert.Equal(organization.UseOrganizationDomains, result.UseOrganizationDomains);
Assert.Equal(organization.UseAdminSponsoredFamilies, result.UseAdminSponsoredFamilies);
Assert.Equal(organization.UseAutomaticUserConfirmation, result.UseAutomaticUserConfirmation);
Assert.Equal(organization.SelfHost, result.SelfHost);
Assert.Equal(organization.Seats, result.Seats);
Assert.Equal(organization.MaxCollections, result.MaxCollections);
Assert.Equal(organization.MaxStorageGb, result.MaxStorageGb);
Assert.Equal(organizationDetails.Key, result.Key);
Assert.True(result.HasPublicAndPrivateKeys);
Assert.Equal(organization.LimitCollectionCreation, result.LimitCollectionCreation);
Assert.Equal(organization.LimitCollectionDeletion, result.LimitCollectionDeletion);
Assert.Equal(organization.LimitItemDeletion, result.LimitItemDeletion);
Assert.Equal(organization.AllowAdminAccessToAllCollectionItems, result.AllowAdminAccessToAllCollectionItems);
Assert.Equal(organizationDetails.ProviderId, result.ProviderId);
Assert.Equal(organizationDetails.ProviderName, result.ProviderName);
Assert.Equal(organizationDetails.ProviderType, result.ProviderType);
Assert.Equal(OrganizationUserStatusType.Confirmed, result.Status);
Assert.Equal(OrganizationUserType.Owner, result.Type);
Assert.Equal(organizationDetails.SsoEnabled, result.SsoEnabled);
Assert.False(result.KeyConnectorEnabled);
Assert.Null(result.KeyConnectorUrl);
Assert.Equal(MemberDecryptionType.TrustedDeviceEncryption, result.SsoMemberDecryptionType);
Assert.False(result.SsoBound);
Assert.NotNull(result.Permissions);
Assert.False(result.Permissions.ManageUsers);
Assert.False(result.ResetPasswordEnrolled);
Assert.False(result.AccessSecretsManager);
}
}

View File

@@ -285,6 +285,10 @@ public class SyncControllerTests
providerUserRepository providerUserRepository
.GetManyDetailsByUserAsync(user.Id, ProviderUserStatusType.Confirmed).Returns(providerUserDetails); .GetManyDetailsByUserAsync(user.Id, ProviderUserStatusType.Confirmed).Returns(providerUserDetails);
foreach (var p in providerUserOrganizationDetails)
{
p.SsoConfig = null;
}
providerUserRepository providerUserRepository
.GetManyOrganizationDetailsByUserAsync(user.Id, ProviderUserStatusType.Confirmed) .GetManyOrganizationDetailsByUserAsync(user.Id, ProviderUserStatusType.Confirmed)
.Returns(providerUserOrganizationDetails); .Returns(providerUserOrganizationDetails);

View File

@@ -0,0 +1,391 @@
using Bit.Billing.Controllers;
using Bit.Billing.Models;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using BitPayLight.Models.Invoice;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using Transaction = Bit.Core.Entities.Transaction;
namespace Bit.Billing.Test.Controllers;
using static BitPayConstants;
public class BitPayControllerTests
{
private readonly GlobalSettings _globalSettings = new();
private readonly IBitPayClient _bitPayClient = Substitute.For<IBitPayClient>();
private readonly ITransactionRepository _transactionRepository = Substitute.For<ITransactionRepository>();
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>();
private readonly IMailService _mailService = Substitute.For<IMailService>();
private readonly IPaymentService _paymentService = Substitute.For<IPaymentService>();
private readonly IPremiumUserBillingService _premiumUserBillingService =
Substitute.For<IPremiumUserBillingService>();
private const string _validWebhookKey = "valid-webhook-key";
private const string _invalidWebhookKey = "invalid-webhook-key";
public BitPayControllerTests()
{
var bitPaySettings = new GlobalSettings.BitPaySettings { WebhookKey = _validWebhookKey };
_globalSettings.BitPay = bitPaySettings;
}
private BitPayController CreateController() => new(
_globalSettings,
_bitPayClient,
_transactionRepository,
_organizationRepository,
_userRepository,
_providerRepository,
_mailService,
_paymentService,
Substitute.For<ILogger<BitPayController>>(),
_premiumUserBillingService);
[Fact]
public async Task PostIpn_InvalidKey_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var result = await controller.PostIpn(eventModel, _invalidWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid key", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_NullKey_ThrowsException()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
await Assert.ThrowsAsync<ArgumentNullException>(() => controller.PostIpn(eventModel, null!));
}
[Fact]
public async Task PostIpn_EmptyKey_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var result = await controller.PostIpn(eventModel, string.Empty);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid key", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_NonUsdCurrency_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(currency: "EUR");
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Cannot process non-USD payments", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_NullPosData_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(posData: null!);
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid POS data", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_EmptyPosData_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(posData: "");
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid POS data", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_PosDataWithoutAccountCredit_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(posData: "organizationId:550e8400-e29b-41d4-a716-446655440000");
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid POS data", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_PosDataWithoutValidId_BadRequest()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(posData: PosDataKeys.AccountCredit);
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Invalid POS data", badRequestResult.Value);
}
[Fact]
public async Task PostIpn_IncompleteInvoice_Ok()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice(status: "paid");
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var okResult = Assert.IsType<OkObjectResult>(result);
Assert.Equal("Waiting for invoice to be completed", okResult.Value);
}
[Fact]
public async Task PostIpn_ExistingTransaction_Ok()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var invoice = CreateValidInvoice();
var existingTransaction = new Transaction { GatewayId = invoice.Id };
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns(existingTransaction);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
var okResult = Assert.IsType<OkObjectResult>(result);
Assert.Equal("Invoice already processed", okResult.Value);
}
[Fact]
public async Task PostIpn_ValidOrganizationTransaction_Success()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var organizationId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"organizationId:{organizationId},{PosDataKeys.AccountCredit}");
var organization = new Organization { Id = organizationId, BillingEmail = "billing@example.com" };
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
_paymentService.CreditAccountAsync(organization, Arg.Any<decimal>()).Returns(true);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
Assert.IsType<OkResult>(result);
await _transactionRepository.Received(1).CreateAsync(Arg.Is<Transaction>(t =>
t.OrganizationId == organizationId &&
t.Type == TransactionType.Credit &&
t.Gateway == GatewayType.BitPay &&
t.PaymentMethodType == PaymentMethodType.BitPay));
await _organizationRepository.Received(1).ReplaceAsync(organization);
await _mailService.Received(1).SendAddedCreditAsync("billing@example.com", 100.00m);
}
[Fact]
public async Task PostIpn_ValidUserTransaction_Success()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var userId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"userId:{userId},{PosDataKeys.AccountCredit}");
var user = new User { Id = userId, Email = "user@example.com" };
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null);
_userRepository.GetByIdAsync(userId).Returns(user);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
Assert.IsType<OkResult>(result);
await _transactionRepository.Received(1).CreateAsync(Arg.Is<Transaction>(t =>
t.UserId == userId &&
t.Type == TransactionType.Credit &&
t.Gateway == GatewayType.BitPay &&
t.PaymentMethodType == PaymentMethodType.BitPay));
await _premiumUserBillingService.Received(1).Credit(user, 100.00m);
await _mailService.Received(1).SendAddedCreditAsync("user@example.com", 100.00m);
}
[Fact]
public async Task PostIpn_ValidProviderTransaction_Success()
{
var controller = CreateController();
var eventModel = CreateValidEventModel();
var providerId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"providerId:{providerId},{PosDataKeys.AccountCredit}");
var provider = new Provider { Id = providerId, BillingEmail = "provider@example.com" };
_bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice);
_transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null);
_providerRepository.GetByIdAsync(providerId).Returns(Task.FromResult(provider));
_paymentService.CreditAccountAsync(provider, Arg.Any<decimal>()).Returns(true);
var result = await controller.PostIpn(eventModel, _validWebhookKey);
Assert.IsType<OkResult>(result);
await _transactionRepository.Received(1).CreateAsync(Arg.Is<Transaction>(t =>
t.ProviderId == providerId &&
t.Type == TransactionType.Credit &&
t.Gateway == GatewayType.BitPay &&
t.PaymentMethodType == PaymentMethodType.BitPay));
await _providerRepository.Received(1).ReplaceAsync(provider);
await _mailService.Received(1).SendAddedCreditAsync("provider@example.com", 100.00m);
}
[Fact]
public void GetIdsFromPosData_ValidOrganizationId_ReturnsCorrectId()
{
var controller = CreateController();
var organizationId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"organizationId:{organizationId},{PosDataKeys.AccountCredit}");
var result = controller.GetIdsFromPosData(invoice);
Assert.Equal(organizationId, result.OrganizationId);
Assert.Null(result.UserId);
Assert.Null(result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_ValidUserId_ReturnsCorrectId()
{
var controller = CreateController();
var userId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"userId:{userId},{PosDataKeys.AccountCredit}");
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Equal(userId, result.UserId);
Assert.Null(result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_ValidProviderId_ReturnsCorrectId()
{
var controller = CreateController();
var providerId = Guid.NewGuid();
var invoice = CreateValidInvoice(posData: $"providerId:{providerId},{PosDataKeys.AccountCredit}");
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Null(result.UserId);
Assert.Equal(providerId, result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_InvalidGuid_ReturnsNull()
{
var controller = CreateController();
var invoice = CreateValidInvoice(posData: "organizationId:invalid-guid,{PosDataKeys.AccountCredit}");
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Null(result.UserId);
Assert.Null(result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_NullPosData_ReturnsNull()
{
var controller = CreateController();
var invoice = CreateValidInvoice(posData: null!);
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Null(result.UserId);
Assert.Null(result.ProviderId);
}
[Fact]
public void GetIdsFromPosData_EmptyPosData_ReturnsNull()
{
var controller = CreateController();
var invoice = CreateValidInvoice(posData: "");
var result = controller.GetIdsFromPosData(invoice);
Assert.Null(result.OrganizationId);
Assert.Null(result.UserId);
Assert.Null(result.ProviderId);
}
private static BitPayEventModel CreateValidEventModel(string invoiceId = "test-invoice-id")
{
return new BitPayEventModel
{
Event = new BitPayEventModel.EventModel { Code = 1005, Name = "invoice_confirmed" },
Data = new BitPayEventModel.InvoiceDataModel { Id = invoiceId }
};
}
private static Invoice CreateValidInvoice(string invoiceId = "test-invoice-id", string status = "complete",
string currency = "USD", decimal price = 100.00m,
string posData = "organizationId:550e8400-e29b-41d4-a716-446655440000,accountCredit:1")
{
return new Invoice
{
Id = invoiceId,
Status = status,
Currency = currency,
Price = (double)price,
PosData = posData,
CurrentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
Transactions =
[
new InvoiceTransaction
{
Type = null,
Confirmations = "1",
ReceivedTime = DateTime.UtcNow.ToString("O")
}
]
};
}
}

View File

@@ -0,0 +1,234 @@
using Bit.Billing.Jobs;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Quartz;
using Xunit;
namespace Bit.Billing.Test.Jobs;
public class ProviderOrganizationDisableJobTests
{
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IOrganizationDisableCommand _organizationDisableCommand;
private readonly ILogger<ProviderOrganizationDisableJob> _logger;
private readonly ProviderOrganizationDisableJob _sut;
public ProviderOrganizationDisableJobTests()
{
_providerOrganizationRepository = Substitute.For<IProviderOrganizationRepository>();
_organizationDisableCommand = Substitute.For<IOrganizationDisableCommand>();
_logger = Substitute.For<ILogger<ProviderOrganizationDisableJob>>();
_sut = new ProviderOrganizationDisableJob(
_providerOrganizationRepository,
_organizationDisableCommand,
_logger);
}
[Fact]
public async Task Execute_NoOrganizations_LogsAndReturns()
{
// Arrange
var providerId = Guid.NewGuid();
var context = CreateJobExecutionContext(providerId, DateTime.UtcNow);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns((ICollection<ProviderOrganizationOrganizationDetails>)null);
// Act
await _sut.Execute(context);
// Assert
await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default);
}
[Fact]
public async Task Execute_WithOrganizations_DisablesAllOrganizations()
{
// Arrange
var providerId = Guid.NewGuid();
var expirationDate = DateTime.UtcNow.AddDays(30);
var org1Id = Guid.NewGuid();
var org2Id = Guid.NewGuid();
var org3Id = Guid.NewGuid();
var organizations = new List<ProviderOrganizationOrganizationDetails>
{
new() { OrganizationId = org1Id },
new() { OrganizationId = org2Id },
new() { OrganizationId = org3Id }
};
var context = CreateJobExecutionContext(providerId, expirationDate);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns(organizations);
// Act
await _sut.Execute(context);
// Assert
await _organizationDisableCommand.Received(1).DisableAsync(org1Id, Arg.Any<DateTime?>());
await _organizationDisableCommand.Received(1).DisableAsync(org2Id, Arg.Any<DateTime?>());
await _organizationDisableCommand.Received(1).DisableAsync(org3Id, Arg.Any<DateTime?>());
}
[Fact]
public async Task Execute_WithExpirationDate_PassesDateToDisableCommand()
{
// Arrange
var providerId = Guid.NewGuid();
var expirationDate = new DateTime(2025, 12, 31, 23, 59, 59);
var orgId = Guid.NewGuid();
var organizations = new List<ProviderOrganizationOrganizationDetails>
{
new() { OrganizationId = orgId }
};
var context = CreateJobExecutionContext(providerId, expirationDate);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns(organizations);
// Act
await _sut.Execute(context);
// Assert
await _organizationDisableCommand.Received(1).DisableAsync(orgId, expirationDate);
}
[Fact]
public async Task Execute_WithNullExpirationDate_PassesNullToDisableCommand()
{
// Arrange
var providerId = Guid.NewGuid();
var orgId = Guid.NewGuid();
var organizations = new List<ProviderOrganizationOrganizationDetails>
{
new() { OrganizationId = orgId }
};
var context = CreateJobExecutionContext(providerId, null);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns(organizations);
// Act
await _sut.Execute(context);
// Assert
await _organizationDisableCommand.Received(1).DisableAsync(orgId, null);
}
[Fact]
public async Task Execute_OneOrganizationFails_ContinuesProcessingOthers()
{
// Arrange
var providerId = Guid.NewGuid();
var expirationDate = DateTime.UtcNow.AddDays(30);
var org1Id = Guid.NewGuid();
var org2Id = Guid.NewGuid();
var org3Id = Guid.NewGuid();
var organizations = new List<ProviderOrganizationOrganizationDetails>
{
new() { OrganizationId = org1Id },
new() { OrganizationId = org2Id },
new() { OrganizationId = org3Id }
};
var context = CreateJobExecutionContext(providerId, expirationDate);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns(organizations);
// Make org2 fail
_organizationDisableCommand.DisableAsync(org2Id, Arg.Any<DateTime?>())
.Throws(new Exception("Database error"));
// Act
await _sut.Execute(context);
// Assert - all three should be attempted
await _organizationDisableCommand.Received(1).DisableAsync(org1Id, Arg.Any<DateTime?>());
await _organizationDisableCommand.Received(1).DisableAsync(org2Id, Arg.Any<DateTime?>());
await _organizationDisableCommand.Received(1).DisableAsync(org3Id, Arg.Any<DateTime?>());
}
[Fact]
public async Task Execute_ManyOrganizations_ProcessesWithLimitedConcurrency()
{
// Arrange
var providerId = Guid.NewGuid();
var expirationDate = DateTime.UtcNow.AddDays(30);
// Create 20 organizations
var organizations = Enumerable.Range(1, 20)
.Select(_ => new ProviderOrganizationOrganizationDetails { OrganizationId = Guid.NewGuid() })
.ToList();
var context = CreateJobExecutionContext(providerId, expirationDate);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns(organizations);
var concurrentCalls = 0;
var maxConcurrentCalls = 0;
var lockObj = new object();
_organizationDisableCommand.DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>())
.Returns(callInfo =>
{
lock (lockObj)
{
concurrentCalls++;
if (concurrentCalls > maxConcurrentCalls)
{
maxConcurrentCalls = concurrentCalls;
}
}
return Task.Delay(50).ContinueWith(_ =>
{
lock (lockObj)
{
concurrentCalls--;
}
});
});
// Act
await _sut.Execute(context);
// Assert
Assert.True(maxConcurrentCalls <= 5, $"Expected max concurrency of 5, but got {maxConcurrentCalls}");
await _organizationDisableCommand.Received(20).DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
}
[Fact]
public async Task Execute_EmptyOrganizationsList_DoesNotCallDisableCommand()
{
// Arrange
var providerId = Guid.NewGuid();
var context = CreateJobExecutionContext(providerId, DateTime.UtcNow);
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
.Returns(new List<ProviderOrganizationOrganizationDetails>());
// Act
await _sut.Execute(context);
// Assert
await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default);
}
private static IJobExecutionContext CreateJobExecutionContext(Guid providerId, DateTime? expirationDate)
{
var context = Substitute.For<IJobExecutionContext>();
var jobDataMap = new JobDataMap
{
{ "providerId", providerId.ToString() },
{ "expirationDate", expirationDate?.ToString("O") }
};
context.MergedJobDataMap.Returns(jobDataMap);
return context;
}
}

View File

@@ -1,10 +1,15 @@
using Bit.Billing.Constants; using Bit.Billing.Constants;
using Bit.Billing.Jobs;
using Bit.Billing.Services; using Bit.Billing.Services;
using Bit.Billing.Services.Implementations; using Bit.Billing.Services.Implementations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Services; using Bit.Core.Services;
using NSubstitute; using NSubstitute;
using Quartz;
using Stripe; using Stripe;
using Xunit; using Xunit;
@@ -16,6 +21,10 @@ public class SubscriptionDeletedHandlerTests
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand;
private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService;
private readonly ISchedulerFactory _schedulerFactory;
private readonly IScheduler _scheduler;
private readonly SubscriptionDeletedHandler _sut; private readonly SubscriptionDeletedHandler _sut;
public SubscriptionDeletedHandlerTests() public SubscriptionDeletedHandlerTests()
@@ -24,11 +33,19 @@ public class SubscriptionDeletedHandlerTests
_userService = Substitute.For<IUserService>(); _userService = Substitute.For<IUserService>();
_stripeEventUtilityService = Substitute.For<IStripeEventUtilityService>(); _stripeEventUtilityService = Substitute.For<IStripeEventUtilityService>();
_organizationDisableCommand = Substitute.For<IOrganizationDisableCommand>(); _organizationDisableCommand = Substitute.For<IOrganizationDisableCommand>();
_providerRepository = Substitute.For<IProviderRepository>();
_providerService = Substitute.For<IProviderService>();
_schedulerFactory = Substitute.For<ISchedulerFactory>();
_scheduler = Substitute.For<IScheduler>();
_schedulerFactory.GetScheduler().Returns(_scheduler);
_sut = new SubscriptionDeletedHandler( _sut = new SubscriptionDeletedHandler(
_stripeEventService, _stripeEventService,
_userService, _userService,
_stripeEventUtilityService, _stripeEventUtilityService,
_organizationDisableCommand); _organizationDisableCommand,
_providerRepository,
_providerService,
_schedulerFactory);
} }
[Fact] [Fact]
@@ -59,6 +76,7 @@ public class SubscriptionDeletedHandlerTests
// Assert // Assert
await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default); await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default);
await _userService.DidNotReceiveWithAnyArgs().DisablePremiumAsync(default, default); await _userService.DidNotReceiveWithAnyArgs().DisablePremiumAsync(default, default);
await _providerService.DidNotReceiveWithAnyArgs().UpdateAsync(default);
} }
[Fact] [Fact]
@@ -192,4 +210,120 @@ public class SubscriptionDeletedHandlerTests
await _organizationDisableCommand.DidNotReceiveWithAnyArgs() await _organizationDisableCommand.DidNotReceiveWithAnyArgs()
.DisableAsync(default, default); .DisableAsync(default, default);
} }
[Fact]
public async Task HandleAsync_ProviderSubscriptionCanceled_DisablesProviderAndQueuesJob()
{
// Arrange
var stripeEvent = new Event();
var providerId = Guid.NewGuid();
var provider = new Provider
{
Id = providerId,
Enabled = true
};
var subscription = new Subscription
{
Status = StripeSubscriptionStatus.Canceled,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
};
_stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_providerRepository.GetByIdAsync(providerId).Returns(provider);
// Act
await _sut.HandleAsync(stripeEvent);
// Assert
Assert.False(provider.Enabled);
await _providerService.Received(1).UpdateAsync(provider);
await _scheduler.Received(1).ScheduleJob(
Arg.Is<IJobDetail>(j => j.JobType == typeof(ProviderOrganizationDisableJob)),
Arg.Any<ITrigger>());
}
[Fact]
public async Task HandleAsync_ProviderSubscriptionCanceled_ProviderNotFound_DoesNotThrow()
{
// Arrange
var stripeEvent = new Event();
var providerId = Guid.NewGuid();
var subscription = new Subscription
{
Status = StripeSubscriptionStatus.Canceled,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
};
_stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_providerRepository.GetByIdAsync(providerId).Returns((Provider)null);
// Act & Assert - Should not throw
await _sut.HandleAsync(stripeEvent);
// Assert
await _providerService.DidNotReceiveWithAnyArgs().UpdateAsync(default);
await _scheduler.DidNotReceiveWithAnyArgs().ScheduleJob(default, default);
}
[Fact]
public async Task HandleAsync_ProviderSubscriptionCanceled_QueuesJobWithCorrectParameters()
{
// Arrange
var stripeEvent = new Event();
var providerId = Guid.NewGuid();
var expirationDate = DateTime.UtcNow.AddDays(30);
var provider = new Provider
{
Id = providerId,
Enabled = true
};
var subscription = new Subscription
{
Status = StripeSubscriptionStatus.Canceled,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = expirationDate }
]
},
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
};
_stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription);
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
_providerRepository.GetByIdAsync(providerId).Returns(provider);
// Act
await _sut.HandleAsync(stripeEvent);
// Assert
Assert.False(provider.Enabled);
await _providerService.Received(1).UpdateAsync(provider);
await _scheduler.Received(1).ScheduleJob(
Arg.Is<IJobDetail>(j =>
j.JobType == typeof(ProviderOrganizationDisableJob) &&
j.JobDataMap.GetString("providerId") == providerId.ToString() &&
j.JobDataMap.GetString("expirationDate") == expirationDate.ToString("O")),
Arg.Is<ITrigger>(t => t.Key.Name == $"disable-trigger-{providerId}"));
}
} }

View File

@@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients; using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Entities; using Bit.Core.Entities;
@@ -11,12 +12,18 @@ using Invoice = BitPayLight.Models.Invoice.Invoice;
namespace Bit.Core.Test.Billing.Payment.Commands; namespace Bit.Core.Test.Billing.Payment.Commands;
using static BitPayConstants;
public class CreateBitPayInvoiceForCreditCommandTests public class CreateBitPayInvoiceForCreditCommandTests
{ {
private readonly IBitPayClient _bitPayClient = Substitute.For<IBitPayClient>(); private readonly IBitPayClient _bitPayClient = Substitute.For<IBitPayClient>();
private readonly GlobalSettings _globalSettings = new() private readonly GlobalSettings _globalSettings = new()
{ {
BitPay = new GlobalSettings.BitPaySettings { NotificationUrl = "https://example.com/bitpay/notification" } BitPay = new GlobalSettings.BitPaySettings
{
NotificationUrl = "https://example.com/bitpay/notification",
WebhookKey = "test-webhook-key"
}
}; };
private const string _redirectUrl = "https://bitwarden.com/redirect"; private const string _redirectUrl = "https://bitwarden.com/redirect";
private readonly CreateBitPayInvoiceForCreditCommand _command; private readonly CreateBitPayInvoiceForCreditCommand _command;
@@ -37,8 +44,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options => _bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
options.Buyer.Email == user.Email && options.Buyer.Email == user.Email &&
options.Buyer.Name == user.Email && options.Buyer.Name == user.Email &&
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl && options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
options.PosData == $"userId:{user.Id},accountCredit:1" && options.PosData == $"userId:{user.Id},{PosDataKeys.AccountCredit}" &&
// ReSharper disable once CompareOfFloatsByEqualityOperator // ReSharper disable once CompareOfFloatsByEqualityOperator
options.Price == Convert.ToDouble(10M) && options.Price == Convert.ToDouble(10M) &&
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" }); options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
@@ -58,8 +65,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options => _bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
options.Buyer.Email == organization.BillingEmail && options.Buyer.Email == organization.BillingEmail &&
options.Buyer.Name == organization.Name && options.Buyer.Name == organization.Name &&
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl && options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
options.PosData == $"organizationId:{organization.Id},accountCredit:1" && options.PosData == $"organizationId:{organization.Id},{PosDataKeys.AccountCredit}" &&
// ReSharper disable once CompareOfFloatsByEqualityOperator // ReSharper disable once CompareOfFloatsByEqualityOperator
options.Price == Convert.ToDouble(10M) && options.Price == Convert.ToDouble(10M) &&
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" }); options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
@@ -79,8 +86,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options => _bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
options.Buyer.Email == provider.BillingEmail && options.Buyer.Email == provider.BillingEmail &&
options.Buyer.Name == provider.Name && options.Buyer.Name == provider.Name &&
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl && options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
options.PosData == $"providerId:{provider.Id},accountCredit:1" && options.PosData == $"providerId:{provider.Id},{PosDataKeys.AccountCredit}" &&
// ReSharper disable once CompareOfFloatsByEqualityOperator // ReSharper disable once CompareOfFloatsByEqualityOperator
options.Price == Convert.ToDouble(10M) && options.Price == Convert.ToDouble(10M) &&
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" }); options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });

View File

@@ -0,0 +1,112 @@
using System.Text.Json;
using Bit.Core.Billing.Payment.Models;
using Xunit;
namespace Bit.Core.Test.Billing.Payment.Models;
public class PaymentMethodTests
{
[Theory]
[InlineData("{\"cardNumber\":\"1234\"}")]
[InlineData("{\"type\":\"unknown_type\",\"data\":\"value\"}")]
[InlineData("{\"type\":\"invalid\",\"token\":\"test-token\"}")]
[InlineData("{\"type\":\"invalid\"}")]
public void Read_ShouldThrowJsonException_OnInvalidOrMissingType(string json)
{
// Arrange
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act & Assert
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PaymentMethod>(json, options));
}
[Theory]
[InlineData("{\"type\":\"card\"}")]
[InlineData("{\"type\":\"card\",\"token\":\"\"}")]
[InlineData("{\"type\":\"card\",\"token\":null}")]
public void Read_ShouldThrowJsonException_OnInvalidTokenizedPaymentMethodToken(string json)
{
// Arrange
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act & Assert
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PaymentMethod>(json, options));
}
// Tokenized payment method deserialization
[Theory]
[InlineData("bankAccount", TokenizablePaymentMethodType.BankAccount)]
[InlineData("card", TokenizablePaymentMethodType.Card)]
[InlineData("payPal", TokenizablePaymentMethodType.PayPal)]
public void Read_ShouldDeserializeTokenizedPaymentMethods(string typeString, TokenizablePaymentMethodType expectedType)
{
// Arrange
var json = $"{{\"type\":\"{typeString}\",\"token\":\"test-token\"}}";
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var result = JsonSerializer.Deserialize<PaymentMethod>(json, options);
// Assert
Assert.True(result.IsTokenized);
Assert.Equal(expectedType, result.AsT0.Type);
Assert.Equal("test-token", result.AsT0.Token);
}
// Non-tokenized payment method deserialization
[Theory]
[InlineData("accountcredit", NonTokenizablePaymentMethodType.AccountCredit)]
public void Read_ShouldDeserializeNonTokenizedPaymentMethods(string typeString, NonTokenizablePaymentMethodType expectedType)
{
// Arrange
var json = $"{{\"type\":\"{typeString}\"}}";
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var result = JsonSerializer.Deserialize<PaymentMethod>(json, options);
// Assert
Assert.True(result.IsNonTokenized);
Assert.Equal(expectedType, result.AsT1.Type);
}
// Tokenized payment method serialization
[Theory]
[InlineData(TokenizablePaymentMethodType.BankAccount, "bankaccount")]
[InlineData(TokenizablePaymentMethodType.Card, "card")]
[InlineData(TokenizablePaymentMethodType.PayPal, "paypal")]
public void Write_ShouldSerializeTokenizedPaymentMethods(TokenizablePaymentMethodType type, string expectedTypeString)
{
// Arrange
var paymentMethod = new PaymentMethod(new TokenizedPaymentMethod
{
Type = type,
Token = "test-token"
});
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var json = JsonSerializer.Serialize(paymentMethod, options);
// Assert
Assert.Contains($"\"type\":\"{expectedTypeString}\"", json);
Assert.Contains("\"token\":\"test-token\"", json);
}
// Non-tokenized payment method serialization
[Theory]
[InlineData(NonTokenizablePaymentMethodType.AccountCredit, "accountcredit")]
public void Write_ShouldSerializeNonTokenizedPaymentMethods(NonTokenizablePaymentMethodType type, string expectedTypeString)
{
// Arrange
var paymentMethod = new PaymentMethod(new NonTokenizedPaymentMethod { Type = type });
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var json = JsonSerializer.Serialize(paymentMethod, options);
// Assert
Assert.Contains($"\"type\":\"{expectedTypeString}\"", json);
Assert.DoesNotContain("token", json);
}
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Billing.Caches; using Bit.Core.Billing;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Models;
@@ -567,4 +568,79 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var unhandled = result.AsT3; var unhandled = result.AsT3;
Assert.Equal("Something went wrong with your request. Please contact support for assistance.", unhandled.Response); Assert.Equal("Something went wrong with your request. Please contact support for assistance.", unhandled.Response);
} }
[Theory, BitAutoData]
public async Task Run_AccountCredit_WithExistingCustomer_Success(
User user,
NonTokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = "existing_customer_123";
paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit;
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "existing_customer_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
_stripeAdapter.InvoiceUpdateAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert
Assert.True(result.IsT0);
await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>());
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
Assert.True(user.Premium);
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
}
[Theory, BitAutoData]
public async Task Run_NonTokenizedPaymentWithoutExistingCustomer_ThrowsBillingException(
User user,
NonTokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
// No existing gateway customer ID
user.GatewayCustomerId = null;
paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit;
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
//Assert
Assert.True(result.IsT3); // Assuming T3 is the Unhandled result
Assert.IsType<BillingException>(result.AsT3.Exception);
// Verify no customer was created or subscription attempted
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
await _stripeAdapter.DidNotReceive().SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
}
} }

View File

@@ -28,6 +28,9 @@
<None Remove="Utilities\data\embeddedResource.txt" /> <None Remove="Utilities\data\embeddedResource.txt" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- Email templates uses .hbs extension, they must be included for emails to work -->
<EmbeddedResource Include="**\*.hbs" />
<EmbeddedResource Include="Utilities\data\embeddedResource.txt" /> <EmbeddedResource Include="Utilities\data\embeddedResource.txt" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,20 @@
using Bit.Core.Platform.Mailer;
using Bit.Core.Test.Platform.Mailer.TestMail;
using Xunit;
namespace Bit.Core.Test.Platform.Mailer;
public class HandlebarMailRendererTests
{
[Fact]
public async Task RenderAsync_ReturnsExpectedHtmlAndTxt()
{
var renderer = new HandlebarMailRenderer();
var view = new TestMailView { Name = "John Smith" };
var (html, txt) = await renderer.RenderAsync(view);
Assert.Equal("Hello <b>John Smith</b>", html.Trim());
Assert.Equal("Hello John Smith", txt.Trim());
}
}

View File

@@ -0,0 +1,37 @@
using Bit.Core.Models.Mail;
using Bit.Core.Platform.Mailer;
using Bit.Core.Services;
using Bit.Core.Test.Platform.Mailer.TestMail;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Platform.Mailer;
public class MailerTest
{
[Fact]
public async Task SendEmailAsync()
{
var deliveryService = Substitute.For<IMailDeliveryService>();
var mailer = new Core.Platform.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService);
var mail = new TestMail.TestMail()
{
ToEmails = ["test@bw.com"],
View = new TestMailView() { Name = "John Smith" }
};
MailMessage? sentMessage = null;
await deliveryService.SendEmailAsync(Arg.Do<MailMessage>(message =>
sentMessage = message
));
await mailer.SendEmail(mail);
Assert.NotNull(sentMessage);
Assert.Contains("test@bw.com", sentMessage.ToEmails);
Assert.Equal("Test Email", sentMessage.Subject);
Assert.Equivalent("Hello John Smith", sentMessage.TextContent.Trim());
Assert.Equivalent("Hello <b>John Smith</b>", sentMessage.HtmlContent.Trim());
}
}

View File

@@ -0,0 +1,13 @@
using Bit.Core.Platform.Mailer;
namespace Bit.Core.Test.Platform.Mailer.TestMail;
public class TestMailView : BaseMailView
{
public required string Name { get; init; }
}
public class TestMail : BaseMail<TestMailView>
{
public override string Subject { get; } = "Test Email";
}

View File

@@ -0,0 +1 @@
Hello <b>{{ Name }}</b>

View File

@@ -0,0 +1 @@
Hello {{ Name }}

View File

@@ -49,6 +49,75 @@ public class OrganizationReportRepositoryTests
Assert.True(records.Count == 4); Assert.True(records.Count == 4);
} }
[CiSkippedTheory, EfOrganizationReportAutoData]
public async Task CreateAsync_ShouldPersistAllMetricProperties_WhenSet(
List<EntityFramework.Dirt.Repositories.OrganizationReportRepository> suts,
List<EfRepo.OrganizationRepository> efOrganizationRepos,
OrganizationReportRepository sqlOrganizationReportRepo,
SqlRepo.OrganizationRepository sqlOrganizationRepo)
{
// Arrange - Create a report with explicit metric values
var fixture = new Fixture();
var organization = fixture.Create<Organization>();
var report = fixture.Build<OrganizationReport>()
.With(x => x.ApplicationCount, 10)
.With(x => x.ApplicationAtRiskCount, 3)
.With(x => x.CriticalApplicationCount, 5)
.With(x => x.CriticalApplicationAtRiskCount, 2)
.With(x => x.MemberCount, 25)
.With(x => x.MemberAtRiskCount, 7)
.With(x => x.CriticalMemberCount, 12)
.With(x => x.CriticalMemberAtRiskCount, 4)
.With(x => x.PasswordCount, 100)
.With(x => x.PasswordAtRiskCount, 15)
.With(x => x.CriticalPasswordCount, 50)
.With(x => x.CriticalPasswordAtRiskCount, 8)
.Create();
var retrievedReports = new List<OrganizationReport>();
// Act & Assert - Test EF repositories
foreach (var sut in suts)
{
var i = suts.IndexOf(sut);
var efOrganization = await efOrganizationRepos[i].CreateAsync(organization);
sut.ClearChangeTracking();
report.OrganizationId = efOrganization.Id;
var createdReport = await sut.CreateAsync(report);
sut.ClearChangeTracking();
var savedReport = await sut.GetByIdAsync(createdReport.Id);
retrievedReports.Add(savedReport);
}
// Act & Assert - Test SQL repository
var sqlOrganization = await sqlOrganizationRepo.CreateAsync(organization);
report.OrganizationId = sqlOrganization.Id;
var sqlCreatedReport = await sqlOrganizationReportRepo.CreateAsync(report);
var savedSqlReport = await sqlOrganizationReportRepo.GetByIdAsync(sqlCreatedReport.Id);
retrievedReports.Add(savedSqlReport);
// Assert - Verify all metric properties are persisted correctly across all repositories
Assert.True(retrievedReports.Count == 4);
foreach (var retrievedReport in retrievedReports)
{
Assert.NotNull(retrievedReport);
Assert.Equal(10, retrievedReport.ApplicationCount);
Assert.Equal(3, retrievedReport.ApplicationAtRiskCount);
Assert.Equal(5, retrievedReport.CriticalApplicationCount);
Assert.Equal(2, retrievedReport.CriticalApplicationAtRiskCount);
Assert.Equal(25, retrievedReport.MemberCount);
Assert.Equal(7, retrievedReport.MemberAtRiskCount);
Assert.Equal(12, retrievedReport.CriticalMemberCount);
Assert.Equal(4, retrievedReport.CriticalMemberAtRiskCount);
Assert.Equal(100, retrievedReport.PasswordCount);
Assert.Equal(15, retrievedReport.PasswordAtRiskCount);
Assert.Equal(50, retrievedReport.CriticalPasswordCount);
Assert.Equal(8, retrievedReport.CriticalPasswordAtRiskCount);
}
}
[CiSkippedTheory, EfOrganizationReportAutoData] [CiSkippedTheory, EfOrganizationReportAutoData]
public async Task RetrieveByOrganisation_Works( public async Task RetrieveByOrganisation_Works(
OrganizationReportRepository sqlOrganizationReportRepo, OrganizationReportRepository sqlOrganizationReportRepo,
@@ -66,6 +135,67 @@ public class OrganizationReportRepositoryTests
Assert.Equal(secondOrg.Id, secondRetrievedReport.OrganizationId); Assert.Equal(secondOrg.Id, secondRetrievedReport.OrganizationId);
} }
[CiSkippedTheory, EfOrganizationReportAutoData]
public async Task UpdateAsync_ShouldUpdateAllMetricProperties_WhenChanged(
OrganizationReportRepository sqlOrganizationReportRepo,
SqlRepo.OrganizationRepository sqlOrganizationRepo)
{
// Arrange - Create initial report with specific metric values
var fixture = new Fixture();
var organization = fixture.Create<Organization>();
var org = await sqlOrganizationRepo.CreateAsync(organization);
var report = fixture.Build<OrganizationReport>()
.With(x => x.OrganizationId, org.Id)
.With(x => x.ApplicationCount, 10)
.With(x => x.ApplicationAtRiskCount, 3)
.With(x => x.CriticalApplicationCount, 5)
.With(x => x.CriticalApplicationAtRiskCount, 2)
.With(x => x.MemberCount, 25)
.With(x => x.MemberAtRiskCount, 7)
.With(x => x.CriticalMemberCount, 12)
.With(x => x.CriticalMemberAtRiskCount, 4)
.With(x => x.PasswordCount, 100)
.With(x => x.PasswordAtRiskCount, 15)
.With(x => x.CriticalPasswordCount, 50)
.With(x => x.CriticalPasswordAtRiskCount, 8)
.Create();
var createdReport = await sqlOrganizationReportRepo.CreateAsync(report);
// Act - Update all metric properties with new values
createdReport.ApplicationCount = 20;
createdReport.ApplicationAtRiskCount = 6;
createdReport.CriticalApplicationCount = 10;
createdReport.CriticalApplicationAtRiskCount = 4;
createdReport.MemberCount = 50;
createdReport.MemberAtRiskCount = 14;
createdReport.CriticalMemberCount = 24;
createdReport.CriticalMemberAtRiskCount = 8;
createdReport.PasswordCount = 200;
createdReport.PasswordAtRiskCount = 30;
createdReport.CriticalPasswordCount = 100;
createdReport.CriticalPasswordAtRiskCount = 16;
await sqlOrganizationReportRepo.UpsertAsync(createdReport);
// Assert - Verify all metric properties were updated correctly
var updatedReport = await sqlOrganizationReportRepo.GetByIdAsync(createdReport.Id);
Assert.NotNull(updatedReport);
Assert.Equal(20, updatedReport.ApplicationCount);
Assert.Equal(6, updatedReport.ApplicationAtRiskCount);
Assert.Equal(10, updatedReport.CriticalApplicationCount);
Assert.Equal(4, updatedReport.CriticalApplicationAtRiskCount);
Assert.Equal(50, updatedReport.MemberCount);
Assert.Equal(14, updatedReport.MemberAtRiskCount);
Assert.Equal(24, updatedReport.CriticalMemberCount);
Assert.Equal(8, updatedReport.CriticalMemberAtRiskCount);
Assert.Equal(200, updatedReport.PasswordCount);
Assert.Equal(30, updatedReport.PasswordAtRiskCount);
Assert.Equal(100, updatedReport.CriticalPasswordCount);
Assert.Equal(16, updatedReport.CriticalPasswordAtRiskCount);
}
[CiSkippedTheory, EfOrganizationReportAutoData] [CiSkippedTheory, EfOrganizationReportAutoData]
public async Task Delete_Works( public async Task Delete_Works(
List<EntityFramework.Dirt.Repositories.OrganizationReportRepository> suts, List<EntityFramework.Dirt.Repositories.OrganizationReportRepository> suts,

View File

@@ -33,14 +33,69 @@ public static class OrganizationTestHelpers
public static Task<Organization> CreateTestOrganizationAsync(this IOrganizationRepository organizationRepository, public static Task<Organization> CreateTestOrganizationAsync(this IOrganizationRepository organizationRepository,
int? seatCount = null, int? seatCount = null,
string identifier = "test") string identifier = "test")
=> organizationRepository.CreateAsync(new Organization
{ {
Name = $"{identifier}-{Guid.NewGuid()}", var id = Guid.NewGuid();
BillingEmail = "billing@example.com", // TODO: EF does not enforce this being NOT NULL return organizationRepository.CreateAsync(new Organization
Plan = "Enterprise (Annually)", // TODO: EF does not enforce this being NOT NULl {
Name = $"{identifier}-{id}",
BillingEmail = $"billing-{id}@example.com",
Plan = "Enterprise (Annually)",
PlanType = PlanType.EnterpriseAnnually, PlanType = PlanType.EnterpriseAnnually,
Seats = seatCount Identifier = $"{identifier}-{id}",
BusinessName = $"Test Business {id}",
BusinessAddress1 = "123 Test Street",
BusinessAddress2 = "Suite 100",
BusinessAddress3 = "Building A",
BusinessCountry = "US",
BusinessTaxNumber = "123456789",
Seats = seatCount,
MaxCollections = 50,
UsePolicies = true,
UseSso = true,
UseKeyConnector = true,
UseScim = true,
UseGroups = true,
UseDirectory = true,
UseEvents = true,
UseTotp = true,
Use2fa = true,
UseApi = true,
UseResetPassword = true,
UseSecretsManager = true,
UsePasswordManager = true,
SelfHost = false,
UsersGetPremium = true,
UseCustomPermissions = true,
Storage = 1073741824, // 1 GB in bytes
MaxStorageGb = 10,
Gateway = GatewayType.Stripe,
GatewayCustomerId = $"cus_{id}",
GatewaySubscriptionId = $"sub_{id}",
ReferenceData = "{\"test\":\"data\"}",
Enabled = true,
LicenseKey = $"license-{id}",
PublicKey = "test-public-key",
PrivateKey = "test-private-key",
TwoFactorProviders = null,
ExpirationDate = DateTime.UtcNow.AddYears(1),
MaxAutoscaleSeats = 200,
OwnersNotifiedOfAutoscaling = null,
Status = OrganizationStatusType.Managed,
SmSeats = 50,
SmServiceAccounts = 25,
MaxAutoscaleSmSeats = 100,
MaxAutoscaleSmServiceAccounts = 50,
LimitCollectionCreation = true,
LimitCollectionDeletion = true,
LimitItemDeletion = true,
AllowAdminAccessToAllCollectionItems = true,
UseRiskInsights = true,
UseOrganizationDomains = true,
UseAdminSponsoredFamilies = true,
SyncSeats = false,
UseAutomaticUserConfirmation = true
}); });
}
/// <summary> /// <summary>
/// Creates a confirmed Owner for the specified organization and user. /// Creates a confirmed Owner for the specified organization and user.

View File

@@ -461,13 +461,7 @@ public class OrganizationUserRepositoryTests
KdfParallelism = 3 KdfParallelism = 3
}); });
var organization = await organizationRepository.CreateAsync(new Organization var organization = await organizationRepository.CreateTestOrganizationAsync();
{
Name = "Test Org",
BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL
Plan = "Test", // TODO: EF does not enforce this being NOT NULL
PrivateKey = "privatekey",
});
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
{ {
@@ -536,9 +530,72 @@ public class OrganizationUserRepositoryTests
Assert.Equal(organization.SmServiceAccounts, result.SmServiceAccounts); Assert.Equal(organization.SmServiceAccounts, result.SmServiceAccounts);
Assert.Equal(organization.LimitCollectionCreation, result.LimitCollectionCreation); Assert.Equal(organization.LimitCollectionCreation, result.LimitCollectionCreation);
Assert.Equal(organization.LimitCollectionDeletion, result.LimitCollectionDeletion); Assert.Equal(organization.LimitCollectionDeletion, result.LimitCollectionDeletion);
Assert.Equal(organization.LimitItemDeletion, result.LimitItemDeletion);
Assert.Equal(organization.AllowAdminAccessToAllCollectionItems, result.AllowAdminAccessToAllCollectionItems); Assert.Equal(organization.AllowAdminAccessToAllCollectionItems, result.AllowAdminAccessToAllCollectionItems);
Assert.Equal(organization.UseRiskInsights, result.UseRiskInsights); Assert.Equal(organization.UseRiskInsights, result.UseRiskInsights);
Assert.Equal(organization.UseOrganizationDomains, result.UseOrganizationDomains);
Assert.Equal(organization.UseAdminSponsoredFamilies, result.UseAdminSponsoredFamilies); Assert.Equal(organization.UseAdminSponsoredFamilies, result.UseAdminSponsoredFamilies);
Assert.Equal(organization.UseAutomaticUserConfirmation, result.UseAutomaticUserConfirmation);
}
[Theory, DatabaseData]
public async Task GetManyDetailsByUserAsync_ShouldPopulateSsoPropertiesCorrectly(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
ISsoConfigRepository ssoConfigRepository)
{
var user = await userRepository.CreateTestUserAsync();
var organizationWithSso = await organizationRepository.CreateTestOrganizationAsync();
var organizationWithoutSso = await organizationRepository.CreateTestOrganizationAsync();
var orgUserWithSso = await organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organizationWithSso.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Owner,
Email = user.Email
});
var orgUserWithoutSso = await organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organizationWithoutSso.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User,
Email = user.Email
});
// Create SSO configuration for first organization only
var serializedSsoConfigData = new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.KeyConnector,
KeyConnectorUrl = "https://keyconnector.example.com"
}.Serialize();
var ssoConfig = await ssoConfigRepository.CreateAsync(new SsoConfig
{
OrganizationId = organizationWithSso.Id,
Enabled = true,
Data = serializedSsoConfigData
});
var results = (await organizationUserRepository.GetManyDetailsByUserAsync(user.Id)).ToList();
Assert.Equal(2, results.Count);
var orgWithSsoDetails = results.Single(r => r.OrganizationId == organizationWithSso.Id);
var orgWithoutSsoDetails = results.Single(r => r.OrganizationId == organizationWithoutSso.Id);
// Organization with SSO should have SSO properties populated
Assert.True(orgWithSsoDetails.SsoEnabled);
Assert.NotNull(orgWithSsoDetails.SsoConfig);
Assert.Equal(serializedSsoConfigData, orgWithSsoDetails.SsoConfig);
// Organization without SSO should have null SSO properties
Assert.Null(orgWithoutSsoDetails.SsoEnabled);
Assert.Null(orgWithoutSsoDetails.SsoConfig);
} }
[DatabaseTheory, DatabaseData] [DatabaseTheory, DatabaseData]

View File

@@ -0,0 +1,142 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories;
public class ProviderUserRepositoryTests
{
[Theory, DatabaseData]
public async Task GetManyOrganizationDetailsByUserAsync_ShouldPopulatePropertiesCorrectly(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository,
ISsoConfigRepository ssoConfigRepository)
{
var user = await userRepository.CreateTestUserAsync();
var organizationWithSso = await organizationRepository.CreateTestOrganizationAsync();
var organizationWithoutSso = await organizationRepository.CreateTestOrganizationAsync();
var provider = await providerRepository.CreateAsync(new Provider
{
Name = "Test Provider",
Enabled = true,
Type = ProviderType.Msp
});
var providerUser = await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider.Id,
UserId = user.Id,
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ProviderAdmin
});
var providerOrganizationWithSso = await providerOrganizationRepository.CreateAsync(new ProviderOrganization
{
ProviderId = provider.Id,
OrganizationId = organizationWithSso.Id
});
var providerOrganizationWithoutSso = await providerOrganizationRepository.CreateAsync(new ProviderOrganization
{
ProviderId = provider.Id,
OrganizationId = organizationWithoutSso.Id
});
// Create SSO configuration for first organization only
var serializedSsoConfigData = new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.KeyConnector,
KeyConnectorUrl = "https://keyconnector.example.com"
}.Serialize();
var ssoConfig = await ssoConfigRepository.CreateAsync(new SsoConfig
{
OrganizationId = organizationWithSso.Id,
Enabled = true,
Data = serializedSsoConfigData
});
var results = (await providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id, ProviderUserStatusType.Confirmed)).ToList();
Assert.Equal(2, results.Count);
var orgWithSsoDetails = results.Single(r => r.OrganizationId == organizationWithSso.Id);
var orgWithoutSsoDetails = results.Single(r => r.OrganizationId == organizationWithoutSso.Id);
// Verify all properties for both organizations
AssertProviderOrganizationDetails(orgWithSsoDetails, organizationWithSso, user, provider, providerUser);
AssertProviderOrganizationDetails(orgWithoutSsoDetails, organizationWithoutSso, user, provider, providerUser);
// Organization without SSO should have null SSO properties
Assert.Null(orgWithoutSsoDetails.SsoEnabled);
Assert.Null(orgWithoutSsoDetails.SsoConfig);
// Organization with SSO should have SSO properties populated
Assert.True(orgWithSsoDetails.SsoEnabled);
Assert.NotNull(orgWithSsoDetails.SsoConfig);
Assert.Equal(serializedSsoConfigData, orgWithSsoDetails.SsoConfig);
}
private static void AssertProviderOrganizationDetails(
ProviderUserOrganizationDetails actual,
Organization expectedOrganization,
User expectedUser,
Provider expectedProvider,
ProviderUser expectedProviderUser)
{
// Organization properties
Assert.Equal(expectedOrganization.Id, actual.OrganizationId);
Assert.Equal(expectedUser.Id, actual.UserId);
Assert.Equal(expectedOrganization.Name, actual.Name);
Assert.Equal(expectedOrganization.UsePolicies, actual.UsePolicies);
Assert.Equal(expectedOrganization.UseSso, actual.UseSso);
Assert.Equal(expectedOrganization.UseKeyConnector, actual.UseKeyConnector);
Assert.Equal(expectedOrganization.UseScim, actual.UseScim);
Assert.Equal(expectedOrganization.UseGroups, actual.UseGroups);
Assert.Equal(expectedOrganization.UseDirectory, actual.UseDirectory);
Assert.Equal(expectedOrganization.UseEvents, actual.UseEvents);
Assert.Equal(expectedOrganization.UseTotp, actual.UseTotp);
Assert.Equal(expectedOrganization.Use2fa, actual.Use2fa);
Assert.Equal(expectedOrganization.UseApi, actual.UseApi);
Assert.Equal(expectedOrganization.UseResetPassword, actual.UseResetPassword);
Assert.Equal(expectedOrganization.UsersGetPremium, actual.UsersGetPremium);
Assert.Equal(expectedOrganization.UseCustomPermissions, actual.UseCustomPermissions);
Assert.Equal(expectedOrganization.SelfHost, actual.SelfHost);
Assert.Equal(expectedOrganization.Seats, actual.Seats);
Assert.Equal(expectedOrganization.MaxCollections, actual.MaxCollections);
Assert.Equal(expectedOrganization.MaxStorageGb, actual.MaxStorageGb);
Assert.Equal(expectedOrganization.Identifier, actual.Identifier);
Assert.Equal(expectedOrganization.PublicKey, actual.PublicKey);
Assert.Equal(expectedOrganization.PrivateKey, actual.PrivateKey);
Assert.Equal(expectedOrganization.Enabled, actual.Enabled);
Assert.Equal(expectedOrganization.PlanType, actual.PlanType);
Assert.Equal(expectedOrganization.LimitCollectionCreation, actual.LimitCollectionCreation);
Assert.Equal(expectedOrganization.LimitCollectionDeletion, actual.LimitCollectionDeletion);
Assert.Equal(expectedOrganization.LimitItemDeletion, actual.LimitItemDeletion);
Assert.Equal(expectedOrganization.AllowAdminAccessToAllCollectionItems, actual.AllowAdminAccessToAllCollectionItems);
Assert.Equal(expectedOrganization.UseRiskInsights, actual.UseRiskInsights);
Assert.Equal(expectedOrganization.UseOrganizationDomains, actual.UseOrganizationDomains);
Assert.Equal(expectedOrganization.UseAdminSponsoredFamilies, actual.UseAdminSponsoredFamilies);
Assert.Equal(expectedOrganization.UseAutomaticUserConfirmation, actual.UseAutomaticUserConfirmation);
// Provider-specific properties
Assert.Equal(expectedProvider.Id, actual.ProviderId);
Assert.Equal(expectedProvider.Name, actual.ProviderName);
Assert.Equal(expectedProvider.Type, actual.ProviderType);
Assert.Equal(expectedProviderUser.Id, actual.ProviderUserId);
Assert.Equal(expectedProviderUser.Status, actual.Status);
Assert.Equal(expectedProviderUser.Type, actual.Type);
}
}

View File

@@ -0,0 +1,64 @@
CREATE OR ALTER VIEW [dbo].[ProviderUserProviderOrganizationDetailsView]
AS
SELECT
PU.[UserId],
PO.[OrganizationId],
O.[Name],
O.[Enabled],
O.[UsePolicies],
O.[UseSso],
O.[UseKeyConnector],
O.[UseScim],
O.[UseGroups],
O.[UseDirectory],
O.[UseEvents],
O.[UseTotp],
O.[Use2fa],
O.[UseApi],
O.[UseResetPassword],
O.[UseSecretsManager],
O.[UsePasswordManager],
O.[SelfHost],
O.[UsersGetPremium],
O.[UseCustomPermissions],
O.[Seats],
O.[MaxCollections],
O.[MaxStorageGb],
O.[Identifier],
PO.[Key],
O.[PublicKey],
O.[PrivateKey],
PU.[Status],
PU.[Type],
PO.[ProviderId],
PU.[Id] ProviderUserId,
P.[Name] ProviderName,
O.[PlanType],
O.[LimitCollectionCreation],
O.[LimitCollectionDeletion],
O.[AllowAdminAccessToAllCollectionItems],
O.[UseRiskInsights],
O.[UseAdminSponsoredFamilies],
P.[Type] ProviderType,
O.[LimitItemDeletion],
O.[UseOrganizationDomains],
O.[UseAutomaticUserConfirmation],
SS.[Enabled] SsoEnabled,
SS.[Data] SsoConfig
FROM
[dbo].[ProviderUser] PU
INNER JOIN
[dbo].[ProviderOrganization] PO ON PO.[ProviderId] = PU.[ProviderId]
INNER JOIN
[dbo].[Organization] O ON O.[Id] = PO.[OrganizationId]
INNER JOIN
[dbo].[Provider] P ON P.[Id] = PU.[ProviderId]
LEFT JOIN
[dbo].[SsoConfig] SS ON SS.[OrganizationId] = O.[Id]
GO
IF OBJECT_ID('[dbo].[ProviderUserProviderOrganizationDetails_ReadByUserIdStatus]') IS NOT NULL
BEGIN
EXECUTE sp_refreshsqlmodule N'[dbo].[ProviderUserProviderOrganizationDetails_ReadByUserIdStatus]';
END
GO

View File

@@ -0,0 +1,161 @@
IF COL_LENGTH('dbo.OrganizationReport', 'ApplicationCount') IS NULL
BEGIN
ALTER TABLE [dbo].[OrganizationReport]
ADD [ApplicationCount] INT NULL,
[ApplicationAtRiskCount] INT NULL,
[CriticalApplicationCount] INT NULL,
[CriticalApplicationAtRiskCount] INT NULL,
[MemberCount] INT NULL,
[MemberAtRiskCount] INT NULL,
[CriticalMemberCount] INT NULL,
[CriticalMemberAtRiskCount] INT NULL,
[PasswordCount] INT NULL,
[PasswordAtRiskCount] INT NULL,
[CriticalPasswordCount] INT NULL,
[CriticalPasswordAtRiskCount] INT NULL
END
GO
CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@OrganizationId UNIQUEIDENTIFIER,
@ReportData NVARCHAR(MAX),
@CreationDate DATETIME2(7),
@ContentEncryptionKey VARCHAR(MAX),
@SummaryData NVARCHAR(MAX),
@ApplicationData NVARCHAR(MAX),
@RevisionDate DATETIME2(7),
@ApplicationCount INT = NULL,
@ApplicationAtRiskCount INT = NULL,
@CriticalApplicationCount INT = NULL,
@CriticalApplicationAtRiskCount INT = NULL,
@MemberCount INT = NULL,
@MemberAtRiskCount INT = NULL,
@CriticalMemberCount INT = NULL,
@CriticalMemberAtRiskCount INT = NULL,
@PasswordCount INT = NULL,
@PasswordAtRiskCount INT = NULL,
@CriticalPasswordCount INT = NULL,
@CriticalPasswordAtRiskCount INT = NULL
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO [dbo].[OrganizationReport](
[Id],
[OrganizationId],
[ReportData],
[CreationDate],
[ContentEncryptionKey],
[SummaryData],
[ApplicationData],
[RevisionDate],
[ApplicationCount],
[ApplicationAtRiskCount],
[CriticalApplicationCount],
[CriticalApplicationAtRiskCount],
[MemberCount],
[MemberAtRiskCount],
[CriticalMemberCount],
[CriticalMemberAtRiskCount],
[PasswordCount],
[PasswordAtRiskCount],
[CriticalPasswordCount],
[CriticalPasswordAtRiskCount]
)
VALUES (
@Id,
@OrganizationId,
@ReportData,
@CreationDate,
@ContentEncryptionKey,
@SummaryData,
@ApplicationData,
@RevisionDate,
@ApplicationCount,
@ApplicationAtRiskCount,
@CriticalApplicationCount,
@CriticalApplicationAtRiskCount,
@MemberCount,
@MemberAtRiskCount,
@CriticalMemberCount,
@CriticalMemberAtRiskCount,
@PasswordCount,
@PasswordAtRiskCount,
@CriticalPasswordCount,
@CriticalPasswordAtRiskCount
);
END
GO
CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_Update]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@ReportData NVARCHAR(MAX),
@CreationDate DATETIME2(7),
@ContentEncryptionKey VARCHAR(MAX),
@SummaryData NVARCHAR(MAX),
@ApplicationData NVARCHAR(MAX),
@RevisionDate DATETIME2(7),
@ApplicationCount INT = NULL,
@ApplicationAtRiskCount INT = NULL,
@CriticalApplicationCount INT = NULL,
@CriticalApplicationAtRiskCount INT = NULL,
@MemberCount INT = NULL,
@MemberAtRiskCount INT = NULL,
@CriticalMemberCount INT = NULL,
@CriticalMemberAtRiskCount INT = NULL,
@PasswordCount INT = NULL,
@PasswordAtRiskCount INT = NULL,
@CriticalPasswordCount INT = NULL,
@CriticalPasswordAtRiskCount INT = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE [dbo].[OrganizationReport]
SET
[OrganizationId] = @OrganizationId,
[ReportData] = @ReportData,
[CreationDate] = @CreationDate,
[ContentEncryptionKey] = @ContentEncryptionKey,
[SummaryData] = @SummaryData,
[ApplicationData] = @ApplicationData,
[RevisionDate] = @RevisionDate,
[ApplicationCount] = @ApplicationCount,
[ApplicationAtRiskCount] = @ApplicationAtRiskCount,
[CriticalApplicationCount] = @CriticalApplicationCount,
[CriticalApplicationAtRiskCount] = @CriticalApplicationAtRiskCount,
[MemberCount] = @MemberCount,
[MemberAtRiskCount] = @MemberAtRiskCount,
[CriticalMemberCount] = @CriticalMemberCount,
[CriticalMemberAtRiskCount] = @CriticalMemberAtRiskCount,
[PasswordCount] = @PasswordCount,
[PasswordAtRiskCount] = @PasswordAtRiskCount,
[CriticalPasswordCount] = @CriticalPasswordCount,
[CriticalPasswordAtRiskCount] = @CriticalPasswordAtRiskCount
WHERE [Id] = @Id;
END;
GO
CREATE OR ALTER VIEW [dbo].[OrganizationReportView]
AS
SELECT
*
FROM
[dbo].[OrganizationReport]
GO
CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_GetLatestByOrganizationId]
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT TOP 1
*
FROM [dbo].[OrganizationReportView]
WHERE [OrganizationId] = @OrganizationId
ORDER BY [RevisionDate] DESC
END
GO

View File

@@ -0,0 +1,137 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class _20251028_00_AddOrganizationReportMetricColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ApplicationAtRiskCount",
table: "OrganizationReport",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ApplicationCount",
table: "OrganizationReport",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalApplicationAtRiskCount",
table: "OrganizationReport",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalApplicationCount",
table: "OrganizationReport",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalMemberAtRiskCount",
table: "OrganizationReport",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalMemberCount",
table: "OrganizationReport",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalPasswordAtRiskCount",
table: "OrganizationReport",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalPasswordCount",
table: "OrganizationReport",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "MemberAtRiskCount",
table: "OrganizationReport",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "MemberCount",
table: "OrganizationReport",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PasswordAtRiskCount",
table: "OrganizationReport",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PasswordCount",
table: "OrganizationReport",
type: "int",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ApplicationAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "ApplicationCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalApplicationAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalApplicationCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalMemberAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalMemberCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalPasswordAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalPasswordCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "MemberAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "MemberCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "PasswordAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "PasswordCount",
table: "OrganizationReport");
}
}

View File

@@ -1017,6 +1017,12 @@ namespace Bit.MySqlMigrations.Migrations
b.Property<Guid>("Id") b.Property<Guid>("Id")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<int?>("ApplicationAtRiskCount")
.HasColumnType("int");
b.Property<int?>("ApplicationCount")
.HasColumnType("int");
b.Property<string>("ApplicationData") b.Property<string>("ApplicationData")
.HasColumnType("longtext"); .HasColumnType("longtext");
@@ -1027,9 +1033,39 @@ namespace Bit.MySqlMigrations.Migrations
b.Property<DateTime>("CreationDate") b.Property<DateTime>("CreationDate")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<int?>("CriticalApplicationAtRiskCount")
.HasColumnType("int");
b.Property<int?>("CriticalApplicationCount")
.HasColumnType("int");
b.Property<int?>("CriticalMemberAtRiskCount")
.HasColumnType("int");
b.Property<int?>("CriticalMemberCount")
.HasColumnType("int");
b.Property<int?>("CriticalPasswordAtRiskCount")
.HasColumnType("int");
b.Property<int?>("CriticalPasswordCount")
.HasColumnType("int");
b.Property<int?>("MemberAtRiskCount")
.HasColumnType("int");
b.Property<int?>("MemberCount")
.HasColumnType("int");
b.Property<Guid>("OrganizationId") b.Property<Guid>("OrganizationId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<int?>("PasswordAtRiskCount")
.HasColumnType("int");
b.Property<int?>("PasswordCount")
.HasColumnType("int");
b.Property<string>("ReportData") b.Property<string>("ReportData")
.IsRequired() .IsRequired()
.HasColumnType("longtext"); .HasColumnType("longtext");

View File

@@ -0,0 +1,137 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class _20251028_00_AddOrganizationReportMetricColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ApplicationAtRiskCount",
table: "OrganizationReport",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ApplicationCount",
table: "OrganizationReport",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalApplicationAtRiskCount",
table: "OrganizationReport",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalApplicationCount",
table: "OrganizationReport",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalMemberAtRiskCount",
table: "OrganizationReport",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalMemberCount",
table: "OrganizationReport",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalPasswordAtRiskCount",
table: "OrganizationReport",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalPasswordCount",
table: "OrganizationReport",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "MemberAtRiskCount",
table: "OrganizationReport",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "MemberCount",
table: "OrganizationReport",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PasswordAtRiskCount",
table: "OrganizationReport",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PasswordCount",
table: "OrganizationReport",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ApplicationAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "ApplicationCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalApplicationAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalApplicationCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalMemberAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalMemberCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalPasswordAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalPasswordCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "MemberAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "MemberCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "PasswordAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "PasswordCount",
table: "OrganizationReport");
}
}

View File

@@ -1022,6 +1022,12 @@ namespace Bit.PostgresMigrations.Migrations
b.Property<Guid>("Id") b.Property<Guid>("Id")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<int?>("ApplicationAtRiskCount")
.HasColumnType("integer");
b.Property<int?>("ApplicationCount")
.HasColumnType("integer");
b.Property<string>("ApplicationData") b.Property<string>("ApplicationData")
.HasColumnType("text"); .HasColumnType("text");
@@ -1032,9 +1038,39 @@ namespace Bit.PostgresMigrations.Migrations
b.Property<DateTime>("CreationDate") b.Property<DateTime>("CreationDate")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<int?>("CriticalApplicationAtRiskCount")
.HasColumnType("integer");
b.Property<int?>("CriticalApplicationCount")
.HasColumnType("integer");
b.Property<int?>("CriticalMemberAtRiskCount")
.HasColumnType("integer");
b.Property<int?>("CriticalMemberCount")
.HasColumnType("integer");
b.Property<int?>("CriticalPasswordAtRiskCount")
.HasColumnType("integer");
b.Property<int?>("CriticalPasswordCount")
.HasColumnType("integer");
b.Property<int?>("MemberAtRiskCount")
.HasColumnType("integer");
b.Property<int?>("MemberCount")
.HasColumnType("integer");
b.Property<Guid>("OrganizationId") b.Property<Guid>("OrganizationId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<int?>("PasswordAtRiskCount")
.HasColumnType("integer");
b.Property<int?>("PasswordCount")
.HasColumnType("integer");
b.Property<string>("ReportData") b.Property<string>("ReportData")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");

View File

@@ -0,0 +1,137 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.SqliteMigrations.Migrations;
/// <inheritdoc />
public partial class _20251028_00_AddOrganizationReportMetricColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ApplicationAtRiskCount",
table: "OrganizationReport",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ApplicationCount",
table: "OrganizationReport",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalApplicationAtRiskCount",
table: "OrganizationReport",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalApplicationCount",
table: "OrganizationReport",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalMemberAtRiskCount",
table: "OrganizationReport",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalMemberCount",
table: "OrganizationReport",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalPasswordAtRiskCount",
table: "OrganizationReport",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CriticalPasswordCount",
table: "OrganizationReport",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "MemberAtRiskCount",
table: "OrganizationReport",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "MemberCount",
table: "OrganizationReport",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PasswordAtRiskCount",
table: "OrganizationReport",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PasswordCount",
table: "OrganizationReport",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ApplicationAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "ApplicationCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalApplicationAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalApplicationCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalMemberAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalMemberCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalPasswordAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "CriticalPasswordCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "MemberAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "MemberCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "PasswordAtRiskCount",
table: "OrganizationReport");
migrationBuilder.DropColumn(
name: "PasswordCount",
table: "OrganizationReport");
}
}

View File

@@ -1006,6 +1006,12 @@ namespace Bit.SqliteMigrations.Migrations
b.Property<Guid>("Id") b.Property<Guid>("Id")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("ApplicationAtRiskCount")
.HasColumnType("INTEGER");
b.Property<int?>("ApplicationCount")
.HasColumnType("INTEGER");
b.Property<string>("ApplicationData") b.Property<string>("ApplicationData")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -1016,9 +1022,39 @@ namespace Bit.SqliteMigrations.Migrations
b.Property<DateTime>("CreationDate") b.Property<DateTime>("CreationDate")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("CriticalApplicationAtRiskCount")
.HasColumnType("INTEGER");
b.Property<int?>("CriticalApplicationCount")
.HasColumnType("INTEGER");
b.Property<int?>("CriticalMemberAtRiskCount")
.HasColumnType("INTEGER");
b.Property<int?>("CriticalMemberCount")
.HasColumnType("INTEGER");
b.Property<int?>("CriticalPasswordAtRiskCount")
.HasColumnType("INTEGER");
b.Property<int?>("CriticalPasswordCount")
.HasColumnType("INTEGER");
b.Property<int?>("MemberAtRiskCount")
.HasColumnType("INTEGER");
b.Property<int?>("MemberCount")
.HasColumnType("INTEGER");
b.Property<Guid>("OrganizationId") b.Property<Guid>("OrganizationId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("PasswordAtRiskCount")
.HasColumnType("INTEGER");
b.Property<int?>("PasswordCount")
.HasColumnType("INTEGER");
b.Property<string>("ReportData") b.Property<string>("ReportData")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");