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:
25
.claude/prompts/review-code.md
Normal file
25
.claude/prompts/review-code.md
Normal 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
5
.github/CODEOWNERS
vendored
@@ -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
28
.github/workflows/respond.yml
vendored
Normal 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
|
||||||
118
.github/workflows/review-code.yml
vendored
118
.github/workflows/review-code.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
15
bitwarden_license/src/Sso/package-lock.json
generated
15
bitwarden_license/src/Sso/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
1029
bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs
Normal file
1029
bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs
Normal file
File diff suppressed because it is too large
Load Diff
35
bitwarden_license/test/SSO.Test/SSO.Test.csproj
Normal file
35
bitwarden_license/test/SSO.Test/SSO.Test.csproj
Normal 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>
|
||||||
15
src/Admin/package-lock.json
generated
15
src/Admin/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}";
|
||||||
}
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Bit.Billing.Constants;
|
|
||||||
|
|
||||||
public static class BitPayInvoiceStatus
|
|
||||||
{
|
|
||||||
public const string Confirmed = "confirmed";
|
|
||||||
public const string Complete = "complete";
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
88
src/Billing/Jobs/ProviderOrganizationDisableJob.cs
Normal file
88
src/Billing/Jobs/ProviderOrganizationDisableJob.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
14
src/Core/Billing/Constants/BitPayConstants.cs
Normal file
14
src/Core/Billing/Constants/BitPayConstants.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs
Normal file
11
src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Bit.Core.Billing.Payment.Models;
|
||||||
|
|
||||||
|
public record NonTokenizedPaymentMethod
|
||||||
|
{
|
||||||
|
public NonTokenizablePaymentMethodType Type { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NonTokenizablePaymentMethodType
|
||||||
|
{
|
||||||
|
AccountCredit,
|
||||||
|
}
|
||||||
69
src/Core/Billing/Payment/Models/PaymentMethod.cs
Normal file
69
src/Core/Billing/Payment/Models/PaymentMethod.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
2373
src/Core/MailTemplates/Mjml/package-lock.json
generated
2373
src/Core/MailTemplates/Mjml/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||||
|
|||||||
54
src/Core/Platform/Mailer/BaseMail.cs
Normal file
54
src/Core/Platform/Mailer/BaseMail.cs
Normal 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();
|
||||||
|
}
|
||||||
80
src/Core/Platform/Mailer/HandlebarMailRenderer.cs
Normal file
80
src/Core/Platform/Mailer/HandlebarMailRenderer.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/Core/Platform/Mailer/IMailRenderer.cs
Normal file
7
src/Core/Platform/Mailer/IMailRenderer.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#nullable enable
|
||||||
|
namespace Bit.Core.Platform.Mailer;
|
||||||
|
|
||||||
|
public interface IMailRenderer
|
||||||
|
{
|
||||||
|
Task<(string html, string txt)> RenderAsync(BaseMailView model);
|
||||||
|
}
|
||||||
15
src/Core/Platform/Mailer/IMailer.cs
Normal file
15
src/Core/Platform/Mailer/IMailer.cs
Normal 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;
|
||||||
|
}
|
||||||
32
src/Core/Platform/Mailer/Mailer.cs
Normal file
32
src/Core/Platform/Mailer/Mailer.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/Core/Platform/Mailer/README.md
Normal file
200
src/Core/Platform/Mailer/README.md
Normal 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>© {{ 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>© {{ 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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>(_ =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
391
test/Billing.Test/Controllers/BitPayControllerTests.cs
Normal file
391
test/Billing.Test/Controllers/BitPayControllerTests.cs
Normal 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")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
234
test/Billing.Test/Jobs/ProviderOrganizationDisableJobTests.cs
Normal file
234
test/Billing.Test/Jobs/ProviderOrganizationDisableJobTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
112
test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs
Normal file
112
test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
20
test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs
Normal file
20
test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
37
test/Core.Test/Platform/Mailer/MailerTest.cs
Normal file
37
test/Core.Test/Platform/Mailer/MailerTest.cs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
13
test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs
Normal file
13
test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs
Normal 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";
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Hello <b>{{ Name }}</b>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Hello {{ Name }}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
3440
util/MySqlMigrations/Migrations/20251028135609_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs
generated
Normal file
3440
util/MySqlMigrations/Migrations/20251028135609_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
3429
util/SqliteMigrations/Migrations/20251028135617_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs
generated
Normal file
3429
util/SqliteMigrations/Migrations/20251028135617_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user