1
0
mirror of https://github.com/bitwarden/server.git synced 2024-12-01 13:43:23 +01:00

Merge branch 'main' into ac/pm-10338/leave-endpoint-to-log-organizationuser_left

This commit is contained in:
Rui Tomé 2024-11-04 16:58:46 +00:00 committed by GitHub
commit dc1acb5355
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
141 changed files with 12746 additions and 1309 deletions

View File

@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"swashbuckle.aspnetcore.cli": { "swashbuckle.aspnetcore.cli": {
"version": "6.8.1", "version": "6.9.0",
"commands": ["swagger"] "commands": ["swagger"]
}, },
"dotnet-ef": { "dotnet-ef": {

View File

@ -29,7 +29,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Check out branch - name: Check out branch
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
@ -53,7 +53,7 @@ jobs:
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }} if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
@ -107,7 +107,7 @@ jobs:
devops-alerts-slack-webhook-url" devops-alerts-slack-webhook-url"
- name: Import GPG keys - name: Import GPG keys
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0 uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0
with: with:
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }} gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }} passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}

View File

@ -18,10 +18,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Verify format - name: Verify format
run: dotnet format --verify-no-changes run: dotnet format --verify-no-changes
@ -67,13 +67,13 @@ jobs:
node: true node: true
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Set up Node - name: Set up Node
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with: with:
cache: "npm" cache: "npm"
cache-dependency-path: "**/package-lock.json" cache-dependency-path: "**/package-lock.json"
@ -172,7 +172,7 @@ jobs:
dotnet: true dotnet: true
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check branch to publish - name: Check branch to publish
env: env:
@ -274,14 +274,14 @@ jobs:
- name: Scan Docker image - name: Scan Docker image
id: container-scan id: container-scan
uses: anchore/scan-action@49e50b215b647c5ec97abb66f69af73c46a4ca08 # v5.0.1 uses: anchore/scan-action@5ed195cc06065322983cae4bb31e2a751feb86fd # v5.2.0
with: with:
image: ${{ steps.image-tags.outputs.primary_tag }} image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false fail-build: false
output-format: sarif output-format: sarif
- name: Upload Grype results to GitHub - name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13 uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
with: with:
sarif_file: ${{ steps.container-scan.outputs.sarif }} sarif_file: ${{ steps.container-scan.outputs.sarif }}
@ -291,10 +291,10 @@ jobs:
needs: build-docker needs: build-docker
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Log in to Azure - production subscription - name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
@ -466,10 +466,10 @@ jobs:
- win-x64 - win-x64
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Print environment - name: Print environment
run: | run: |

View File

@ -12,7 +12,7 @@ jobs:
config-exists: ${{ steps.validate-config.outputs.config-exists }} config-exists: ${{ steps.validate-config.outputs.config-exists }}
steps: steps:
- name: Checkout PR - name: Checkout PR
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate config exists in path - name: Validate config exists in path
id: validate-config id: validate-config

View File

@ -23,7 +23,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope" secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Checkout main - name: Checkout main
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}

View File

@ -33,7 +33,7 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Collect - name: Collect
id: collect id: collect

View File

@ -28,7 +28,7 @@ jobs:
label: "DB-migrations-changed" label: "DB-migrations-changed"
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 2 fetch-depth: 2

View File

@ -98,7 +98,7 @@ jobs:
echo "Github Release Option: $RELEASE_OPTION" echo "Github Release Option: $RELEASE_OPTION"
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up project name - name: Set up project name
id: setup id: setup

View File

@ -36,7 +36,7 @@ jobs:
fi fi
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check release version - name: Check release version
id: version id: version

View File

@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out target ref - name: Check out target ref
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ inputs.target_ref }} ref: ${{ inputs.target_ref }}
@ -62,7 +62,7 @@ jobs:
version: ${{ inputs.version_number_override }} version: ${{ inputs.version_number_override }}
- name: Check out branch - name: Check out branch
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main
@ -135,13 +135,61 @@ jobs:
git config --local user.email "actions@github.com" git config --local user.email "actions@github.com"
git config --local user.name "Github Actions" git config --local user.name "Github Actions"
- name: Create version branch
id: create-branch
run: |
NAME=version_bump_${{ github.ref_name }}_$(date +"%Y-%m-%d")
git switch -c $NAME
echo "name=$NAME" >> $GITHUB_OUTPUT
- name: Commit files - name: Commit files
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
- name: Push changes - name: Push changes
run: git push
- name: Generate GH App token
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
owner: ${{ github.repository_owner }}
- name: Create version PR
id: create-pr
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_BRANCH: ${{ steps.create-branch.outputs.name }}
TITLE: "Bump version to ${{ steps.set-final-version-output.outputs.version }}"
run: | run: |
git pull -pt PR_URL=$(gh pr create --title "$TITLE" \
git push --base "main" \
--head "$PR_BRANCH" \
--label "version update" \
--label "automated pr" \
--body "
## Type of change
- [ ] Bug fix
- [ ] New feature development
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
- [ ] Build/deploy pipeline (DevOps)
- [X] Other
## Objective
Automated version bump to ${{ steps.set-final-version-output.outputs.version }}")
echo "pr_number=${PR_URL##*/}" >> $GITHUB_OUTPUT
- name: Approve PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
run: gh pr review $PR_NUMBER --approve
- name: Merge PR
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
run: gh pr merge $PR_NUMBER --squash --auto --delete-branch
cherry_pick: cherry_pick:
@ -150,7 +198,7 @@ jobs:
needs: bump_version needs: bump_version
steps: steps:
- name: Check out main branch - name: Check out main branch
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: main ref: main

View File

@ -26,7 +26,7 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
@ -46,7 +46,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }} --output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub - name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@f779452ac5af1c261dce0346a8f964149f49322b # v3.26.13 uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
with: with:
sarif_file: cx_result.sarif sarif_file: cx_result.sarif
@ -60,19 +60,19 @@ jobs:
steps: steps:
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.4.0 uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with: with:
java-version: 17 java-version: 17
distribution: "zulu" distribution: "zulu"
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Install SonarCloud scanner - name: Install SonarCloud scanner
run: dotnet tool install dotnet-sonarscanner -g run: dotnet tool install dotnet-sonarscanner -g

View File

@ -35,10 +35,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Restore tools - name: Restore tools
run: dotnet tool restore run: dotnet tool restore
@ -146,10 +146,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Print environment - name: Print environment
run: | run: |

View File

@ -46,10 +46,10 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Print environment - name: Print environment
run: | run: |

View File

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

View File

@ -40,6 +40,36 @@ public class CreateProviderCommand : ICreateProviderCommand
} }
public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats) public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats)
{
var providerId = await CreateProviderAsync(provider, ownerEmail);
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (isConsolidatedBillingEnabled)
{
await CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats);
await CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats);
}
}
public async Task CreateResellerAsync(Provider provider)
{
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
}
public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats)
{
var providerId = await CreateProviderAsync(provider, ownerEmail);
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (isConsolidatedBillingEnabled)
{
await CreateProviderPlanAsync(providerId, plan, minimumSeats);
}
}
private async Task<Guid> CreateProviderAsync(Provider provider, string ownerEmail)
{ {
var owner = await _userRepository.GetByEmailAsync(ownerEmail); var owner = await _userRepository.GetByEmailAsync(ownerEmail);
if (owner == null) if (owner == null)
@ -64,27 +94,10 @@ public class CreateProviderCommand : ICreateProviderCommand
Status = ProviderUserStatusType.Confirmed, Status = ProviderUserStatusType.Confirmed,
}; };
if (isConsolidatedBillingEnabled)
{
var providerPlans = new List<ProviderPlan>
{
CreateProviderPlan(provider.Id, PlanType.TeamsMonthly, teamsMinimumSeats),
CreateProviderPlan(provider.Id, PlanType.EnterpriseMonthly, enterpriseMinimumSeats)
};
foreach (var providerPlan in providerPlans)
{
await _providerPlanRepository.CreateAsync(providerPlan);
}
}
await _providerUserRepository.CreateAsync(providerUser); await _providerUserRepository.CreateAsync(providerUser);
await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email); await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email);
}
public async Task CreateResellerAsync(Provider provider) return provider.Id;
{
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
} }
private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status) private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status)
@ -95,9 +108,9 @@ public class CreateProviderCommand : ICreateProviderCommand
await _providerRepository.CreateAsync(provider); await _providerRepository.CreateAsync(provider);
} }
private ProviderPlan CreateProviderPlan(Guid providerId, PlanType planType, int seatMinimum) private async Task CreateProviderPlanAsync(Guid providerId, PlanType planType, int seatMinimum)
{ {
return new ProviderPlan var plan = new ProviderPlan
{ {
ProviderId = providerId, ProviderId = providerId,
PlanType = planType, PlanType = planType,
@ -105,5 +118,6 @@ public class CreateProviderCommand : ICreateProviderCommand
PurchasedSeats = 0, PurchasedSeats = 0,
AllocatedSeats = 0 AllocatedSeats = 0
}; };
await _providerPlanRepository.CreateAsync(plan);
} }
} }

View File

@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -379,42 +380,23 @@ public class ProviderBillingService(
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>(); var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
var teamsProviderPlan = foreach (var providerPlan in providerPlans)
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
{ {
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Teams plan", provider.Id); var plan = StaticStore.GetPlan(providerPlan.PlanType);
if (!providerPlan.IsConfigured())
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", provider.Id, plan.Name);
throw new BillingException(); throw new BillingException();
} }
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
Price = teamsPlan.PasswordManager.StripeProviderPortalSeatPlanId, Price = plan.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = teamsProviderPlan.SeatMinimum Quantity = providerPlan.SeatMinimum
}); });
var enterpriseProviderPlan =
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured())
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Enterprise plan", provider.Id);
throw new BillingException();
} }
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = enterprisePlan.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = enterpriseProviderPlan.SeatMinimum
});
var subscriptionCreateOptions = new SubscriptionCreateOptions var subscriptionCreateOptions = new SubscriptionCreateOptions
{ {
AutomaticTax = new SubscriptionAutomaticTaxOptions AutomaticTax = new SubscriptionAutomaticTaxOptions
@ -456,144 +438,142 @@ public class ProviderBillingService(
} }
} }
public async Task UpdateSeatMinimums( public async Task ChangePlan(ChangeProviderPlanCommand command)
Provider provider,
int enterpriseSeatMinimum,
int teamsSeatMinimum)
{ {
ArgumentNullException.ThrowIfNull(provider); var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
if (enterpriseSeatMinimum < 0 || teamsSeatMinimum < 0) if (plan == null)
{
throw new BadRequestException("Provider plan not found.");
}
if (plan.PlanType == command.NewPlan)
{
return;
}
var oldPlanConfiguration = StaticStore.GetPlan(plan.PlanType);
plan.PlanType = command.NewPlan;
await providerPlanRepository.ReplaceAsync(plan);
Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
var updateOptions = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Price = StaticStore.GetPlan(command.NewPlan).PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = oldSubscriptionItem!.Quantity
},
new SubscriptionItemOptions
{
Id = oldSubscriptionItem.Id,
Deleted = true
}
]
};
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
}
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
{
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
{ {
throw new BadRequestException("Provider seat minimums must be at least 0."); throw new BadRequestException("Provider seat minimums must be at least 0.");
} }
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId); Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, command.Id);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>(); var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); var providerPlans = await providerPlanRepository.GetByProviderId(command.Id);
var enterpriseProviderPlan = foreach (var newPlanConfiguration in command.Configuration)
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan.SeatMinimum != enterpriseSeatMinimum)
{ {
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager var providerPlan =
providerPlans.Single(providerPlan => providerPlan.PlanType == newPlanConfiguration.Plan);
if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum)
{
var priceId = StaticStore.GetPlan(newPlanConfiguration.Plan).PasswordManager
.StripeProviderPortalSeatPlanId; .StripeProviderPortalSeatPlanId;
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
var enterpriseSubscriptionItem = subscription.Items.First(item => item.Price.Id == enterprisePriceId); if (providerPlan.PurchasedSeats == 0)
if (enterpriseProviderPlan.PurchasedSeats == 0)
{ {
if (enterpriseProviderPlan.AllocatedSeats > enterpriseSeatMinimum) if (providerPlan.AllocatedSeats > newPlanConfiguration.SeatsMinimum)
{ {
enterpriseProviderPlan.PurchasedSeats = providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - newPlanConfiguration.SeatsMinimum;
enterpriseProviderPlan.AllocatedSeats - enterpriseSeatMinimum;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
Id = enterpriseSubscriptionItem.Id, Id = subscriptionItem.Id,
Price = enterprisePriceId, Price = priceId,
Quantity = enterpriseProviderPlan.AllocatedSeats Quantity = providerPlan.AllocatedSeats
}); });
} }
else else
{ {
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
Id = enterpriseSubscriptionItem.Id, Id = subscriptionItem.Id,
Price = enterprisePriceId, Price = priceId,
Quantity = enterpriseSeatMinimum Quantity = newPlanConfiguration.SeatsMinimum
}); });
} }
} }
else else
{ {
var totalEnterpriseSeats = enterpriseProviderPlan.SeatMinimum + enterpriseProviderPlan.PurchasedSeats; var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
if (enterpriseSeatMinimum <= totalEnterpriseSeats) if (newPlanConfiguration.SeatsMinimum <= totalSeats)
{ {
enterpriseProviderPlan.PurchasedSeats = totalEnterpriseSeats - enterpriseSeatMinimum; providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum;
} }
else else
{ {
enterpriseProviderPlan.PurchasedSeats = 0; providerPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
Id = enterpriseSubscriptionItem.Id, Id = subscriptionItem.Id,
Price = enterprisePriceId, Price = priceId,
Quantity = enterpriseSeatMinimum Quantity = newPlanConfiguration.SeatsMinimum
}); });
} }
} }
enterpriseProviderPlan.SeatMinimum = enterpriseSeatMinimum; providerPlan.SeatMinimum = newPlanConfiguration.SeatsMinimum;
await providerPlanRepository.ReplaceAsync(enterpriseProviderPlan); await providerPlanRepository.ReplaceAsync(providerPlan);
} }
var teamsProviderPlan =
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan.SeatMinimum != teamsSeatMinimum)
{
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager
.StripeProviderPortalSeatPlanId;
var teamsSubscriptionItem = subscription.Items.First(item => item.Price.Id == teamsPriceId);
if (teamsProviderPlan.PurchasedSeats == 0)
{
if (teamsProviderPlan.AllocatedSeats > teamsSeatMinimum)
{
teamsProviderPlan.PurchasedSeats = teamsProviderPlan.AllocatedSeats - teamsSeatMinimum;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsProviderPlan.AllocatedSeats
});
}
else
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsSeatMinimum
});
}
}
else
{
var totalTeamsSeats = teamsProviderPlan.SeatMinimum + teamsProviderPlan.PurchasedSeats;
if (teamsSeatMinimum <= totalTeamsSeats)
{
teamsProviderPlan.PurchasedSeats = totalTeamsSeats - teamsSeatMinimum;
}
else
{
teamsProviderPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = teamsSubscriptionItem.Id,
Price = teamsPriceId,
Quantity = teamsSeatMinimum
});
}
}
teamsProviderPlan.SeatMinimum = teamsSeatMinimum;
await providerPlanRepository.ReplaceAsync(teamsProviderPlan);
} }
if (subscriptionItemOptionsList.Count > 0) if (subscriptionItemOptionsList.Count > 0)
{ {
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId,
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList }); new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
} }
} }

View File

@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -19,23 +20,30 @@ public class CreateProviderCommandTests
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider<CreateProviderCommand> sutProvider) public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
{ {
// Arrange
provider.Type = ProviderType.Msp; provider.Type = ProviderType.Msp;
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>( var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateMspAsync(provider, default, default, default)); () => sutProvider.Sut.CreateMspAsync(provider, default, default, default));
// Assert
Assert.Contains("Invalid owner.", exception.Message); Assert.Contains("Invalid owner.", exception.Message);
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider) public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider)
{ {
// Arrange
provider.Type = ProviderType.Msp; provider.Type = ProviderType.Msp;
var userRepository = sutProvider.GetDependency<IUserRepository>(); var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user); userRepository.GetByEmailAsync(user.Email).Returns(user);
// Act
await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default); await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default); await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email); await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
} }
@ -43,11 +51,52 @@ public class CreateProviderCommandTests
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider) public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
{ {
// Arrange
provider.Type = ProviderType.Reseller; provider.Type = ProviderType.Reseller;
// Act
await sutProvider.Sut.CreateResellerAsync(provider); await sutProvider.Sut.CreateResellerAsync(provider);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default); await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default); await sutProvider.GetDependency<IProviderService>().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default);
} }
[Theory, BitAutoData]
public async Task CreateMultiOrganizationEnterpriseAsync_Success(
Provider provider,
User user,
PlanType plan,
int minimumSeats,
SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.MultiOrganizationEnterprise;
var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user);
// Act
await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(provider);
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
}
[Theory, BitAutoData]
public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws(
Provider provider,
SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.Msp;
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default));
// Assert
Assert.Contains("Invalid owner.", exception.Message);
}
} }

View File

@ -13,6 +13,7 @@ using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -1011,26 +1012,192 @@ public class ProviderBillingServiceTests
#endregion #endregion
#region UpdateSeatMinimums #region ChangePlan
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task UpdateSeatMinimums_NullProvider_ThrowsArgumentNullException( public async Task ChangePlan_NullProviderPlan_ThrowsBadRequestException(
SutProvider<ProviderBillingService> sutProvider) => ChangeProviderPlanCommand command,
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.UpdateSeatMinimums(null, 0, 0)); SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
providerPlanRepository.GetByIdAsync(Arg.Any<Guid>()).Returns((ProviderPlan)null);
// Act
var actual = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.ChangePlan(command));
// Assert
Assert.Equal("Provider plan not found.", actual.Message);
}
[Theory, BitAutoData]
public async Task ChangePlan_ProviderNotFound_DoesNothing(
ChangeProviderPlanCommand command,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var existingPlan = new ProviderPlan
{
Id = command.ProviderPlanId,
PlanType = command.NewPlan,
PurchasedSeats = 0,
AllocatedSeats = 0,
SeatMinimum = 0
};
providerPlanRepository
.GetByIdAsync(Arg.Is<Guid>(p => p == command.ProviderPlanId))
.Returns(existingPlan);
// Act
await sutProvider.Sut.ChangePlan(command);
// Assert
await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any<ProviderPlan>());
await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Theory, BitAutoData]
public async Task ChangePlan_SameProviderPlan_DoesNothing(
ChangeProviderPlanCommand command,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var existingPlan = new ProviderPlan
{
Id = command.ProviderPlanId,
PlanType = command.NewPlan,
PurchasedSeats = 0,
AllocatedSeats = 0,
SeatMinimum = 0
};
providerPlanRepository
.GetByIdAsync(Arg.Is<Guid>(p => p == command.ProviderPlanId))
.Returns(existingPlan);
// Act
await sutProvider.Sut.ChangePlan(command);
// Assert
await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any<ProviderPlan>());
await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Theory, BitAutoData]
public async Task ChangePlan_UpdatesSubscriptionCorrectly(
Guid providerPlanId,
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var existingPlan = new ProviderPlan
{
Id = providerPlanId,
ProviderId = provider.Id,
PlanType = PlanType.EnterpriseAnnually,
PurchasedSeats = 2,
AllocatedSeats = 10,
SeatMinimum = 8
};
providerPlanRepository
.GetByIdAsync(Arg.Is<Guid>(p => p == providerPlanId))
.Returns(existingPlan);
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(Arg.Is(existingPlan.ProviderId)).Returns(provider);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.ProviderSubscriptionGetAsync(
Arg.Is(provider.GatewaySubscriptionId),
Arg.Is(provider.Id))
.Returns(new Subscription
{
Id = provider.GatewaySubscriptionId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Id = "si_ent_annual",
Price = new Price
{
Id = StaticStore.GetPlan(PlanType.EnterpriseAnnually).PasswordManager
.StripeProviderPortalSeatPlanId
},
Quantity = 10
}
]
}
});
var command =
new ChangeProviderPlanCommand(providerPlanId, PlanType.EnterpriseMonthly, provider.GatewaySubscriptionId);
// Act
await sutProvider.Sut.ChangePlan(command);
// Assert
await providerPlanRepository.Received(1)
.ReplaceAsync(Arg.Is<ProviderPlan>(p => p.PlanType == PlanType.EnterpriseMonthly));
await stripeAdapter.Received(1)
.SubscriptionUpdateAsync(
Arg.Is(provider.GatewaySubscriptionId),
Arg.Is<SubscriptionUpdateOptions>(p =>
p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1));
var newPlanCfg = StaticStore.GetPlan(command.NewPlan);
await stripeAdapter.Received(1)
.SubscriptionUpdateAsync(
Arg.Is(provider.GatewaySubscriptionId),
Arg.Is<SubscriptionUpdateOptions>(p =>
p.Items.Count(si =>
si.Price == newPlanCfg.PasswordManager.StripeProviderPortalSeatPlanId &&
si.Deleted == default &&
si.Quantity == 10) == 1));
}
#endregion
#region UpdateSeatMinimums
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task UpdateSeatMinimums_NegativeSeatMinimum_ThrowsBadRequestException( public async Task UpdateSeatMinimums_NegativeSeatMinimum_ThrowsBadRequestException(
Provider provider, Provider provider,
SutProvider<ProviderBillingService> sutProvider) => SutProvider<ProviderBillingService> sutProvider)
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSeatMinimums(provider, -10, 100)); {
// Arrange
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(PlanType.TeamsMonthly, -10),
(PlanType.EnterpriseMonthly, 50)
]);
// Act
var actual = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSeatMinimums(command));
// Assert
Assert.Equal("Provider seat minimums must be at least 0.", actual.Message);
}
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task UpdateSeatMinimums_NoPurchasedSeats_AllocatedHigherThanIncomingMinimum_UpdatesPurchasedSeats_SyncsStripeWithNewSeatMinimum( public async Task UpdateSeatMinimums_NoPurchasedSeats_AllocatedHigherThanIncomingMinimum_UpdatesPurchasedSeats_SyncsStripeWithNewSeatMinimum(
Provider provider, Provider provider,
SutProvider<ProviderBillingService> sutProvider) SutProvider<ProviderBillingService> sutProvider)
{ {
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>(); var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
@ -1058,7 +1225,9 @@ public class ProviderBillingServiceTests
} }
}; };
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); stripeAdapter.ProviderSubscriptionGetAsync(
provider.GatewaySubscriptionId,
provider.Id).Returns(subscription);
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -1066,10 +1235,21 @@ public class ProviderBillingServiceTests
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0, AllocatedSeats = 25 } new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0, AllocatedSeats = 25 }
}; };
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
await sutProvider.Sut.UpdateSeatMinimums(provider, 30, 20); var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(PlanType.EnterpriseMonthly, 30),
(PlanType.TeamsMonthly, 20)
]);
// Act
await sutProvider.Sut.UpdateSeatMinimums(command);
// Assert
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 30)); providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 30));
@ -1091,8 +1271,11 @@ public class ProviderBillingServiceTests
Provider provider, Provider provider,
SutProvider<ProviderBillingService> sutProvider) SutProvider<ProviderBillingService> sutProvider)
{ {
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>(); var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
@ -1120,7 +1303,7 @@ public class ProviderBillingServiceTests
} }
}; };
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -1130,8 +1313,18 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
await sutProvider.Sut.UpdateSeatMinimums(provider, 70, 50); var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(PlanType.EnterpriseMonthly, 70),
(PlanType.TeamsMonthly, 50)
]);
// Act
await sutProvider.Sut.UpdateSeatMinimums(command);
// Assert
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70)); providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70));
@ -1153,8 +1346,11 @@ public class ProviderBillingServiceTests
Provider provider, Provider provider,
SutProvider<ProviderBillingService> sutProvider) SutProvider<ProviderBillingService> sutProvider)
{ {
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>(); var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
@ -1182,7 +1378,7 @@ public class ProviderBillingServiceTests
} }
}; };
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -1192,8 +1388,18 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
await sutProvider.Sut.UpdateSeatMinimums(provider, 60, 60); var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(PlanType.EnterpriseMonthly, 60),
(PlanType.TeamsMonthly, 60)
]);
// Act
await sutProvider.Sut.UpdateSeatMinimums(command);
// Assert
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10)); providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10));
@ -1209,8 +1415,11 @@ public class ProviderBillingServiceTests
Provider provider, Provider provider,
SutProvider<ProviderBillingService> sutProvider) SutProvider<ProviderBillingService> sutProvider)
{ {
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>(); var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
@ -1238,7 +1447,7 @@ public class ProviderBillingServiceTests
} }
}; };
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -1248,8 +1457,18 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
await sutProvider.Sut.UpdateSeatMinimums(provider, 80, 80); var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(PlanType.EnterpriseMonthly, 80),
(PlanType.TeamsMonthly, 80)
]);
// Act
await sutProvider.Sut.UpdateSeatMinimums(command);
// Assert
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0)); providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0));
@ -1271,8 +1490,11 @@ public class ProviderBillingServiceTests
Provider provider, Provider provider,
SutProvider<ProviderBillingService> sutProvider) SutProvider<ProviderBillingService> sutProvider)
{ {
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>(); var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>(); var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
@ -1300,7 +1522,7 @@ public class ProviderBillingServiceTests
} }
}; };
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId).Returns(subscription); stripeAdapter.ProviderSubscriptionGetAsync(provider.GatewaySubscriptionId, provider.Id).Returns(subscription);
var providerPlans = new List<ProviderPlan> var providerPlans = new List<ProviderPlan>
{ {
@ -1310,8 +1532,18 @@ public class ProviderBillingServiceTests
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
await sutProvider.Sut.UpdateSeatMinimums(provider, 70, 30); var command = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(PlanType.EnterpriseMonthly, 70),
(PlanType.TeamsMonthly, 30)
]);
// Act
await sutProvider.Sut.UpdateSeatMinimums(command);
// Assert
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>( await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70)); providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70));

View File

@ -9,7 +9,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" /> <PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" /> <PackageReference Include="xunit" Version="$(XUnitVersion)" />

View File

@ -14,6 +14,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -107,9 +108,15 @@ public class ProvidersController : Controller
}); });
} }
public IActionResult Create(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null) public IActionResult Create()
{ {
return View(new CreateProviderModel return View(new CreateProviderModel());
}
[HttpGet("providers/create/msp")]
public IActionResult CreateMsp(int teamsMinimumSeats, int enterpriseMinimumSeats, string ownerEmail = null)
{
return View(new CreateMspProviderModel
{ {
OwnerEmail = ownerEmail, OwnerEmail = ownerEmail,
TeamsMonthlySeatMinimum = teamsMinimumSeats, TeamsMonthlySeatMinimum = teamsMinimumSeats,
@ -117,10 +124,50 @@ public class ProvidersController : Controller
}); });
} }
[HttpGet("providers/create/reseller")]
public IActionResult CreateReseller()
{
return View(new CreateResellerProviderModel());
}
[HttpGet("providers/create/multi-organization-enterprise")]
public IActionResult CreateMultiOrganizationEnterprise(int enterpriseMinimumSeats, string ownerEmail = null)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
{
return RedirectToAction("Create");
}
return View(new CreateMultiOrganizationEnterpriseProviderModel
{
OwnerEmail = ownerEmail,
EnterpriseSeatMinimum = enterpriseMinimumSeats
});
}
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)] [RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> Create(CreateProviderModel model) public IActionResult Create(CreateProviderModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
return model.Type switch
{
ProviderType.Msp => RedirectToAction("CreateMsp"),
ProviderType.Reseller => RedirectToAction("CreateReseller"),
ProviderType.MultiOrganizationEnterprise => RedirectToAction("CreateMultiOrganizationEnterprise"),
_ => View(model)
};
}
[HttpPost("providers/create/msp")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> CreateMsp(CreateMspProviderModel model)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
@ -128,20 +175,52 @@ public class ProvidersController : Controller
} }
var provider = model.ToProvider(); var provider = model.ToProvider();
switch (provider.Type)
{
case ProviderType.Msp:
await _createProviderCommand.CreateMspAsync( await _createProviderCommand.CreateMspAsync(
provider, provider,
model.OwnerEmail, model.OwnerEmail,
model.TeamsMonthlySeatMinimum, model.TeamsMonthlySeatMinimum,
model.EnterpriseMonthlySeatMinimum); model.EnterpriseMonthlySeatMinimum);
break;
case ProviderType.Reseller: return RedirectToAction("Edit", new { id = provider.Id });
await _createProviderCommand.CreateResellerAsync(provider);
break;
} }
[HttpPost("providers/create/reseller")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> CreateReseller(CreateResellerProviderModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var provider = model.ToProvider();
await _createProviderCommand.CreateResellerAsync(provider);
return RedirectToAction("Edit", new { id = provider.Id });
}
[HttpPost("providers/create/multi-organization-enterprise")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> CreateMultiOrganizationEnterprise(CreateMultiOrganizationEnterpriseProviderModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var provider = model.ToProvider();
if (!_featureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
{
return RedirectToAction("Create");
}
await _createProviderCommand.CreateMultiOrganizationEnterpriseAsync(
provider,
model.OwnerEmail,
model.Plan.Value,
model.EnterpriseSeatMinimum);
return RedirectToAction("Edit", new { id = provider.Id }); return RedirectToAction("Edit", new { id = provider.Id });
} }
@ -212,26 +291,40 @@ public class ProvidersController : Controller
var providerPlans = await _providerPlanRepository.GetByProviderId(id); var providerPlans = await _providerPlanRepository.GetByProviderId(id);
if (providerPlans.Count == 0) switch (provider.Type)
{ {
var newProviderPlans = new List<ProviderPlan> case ProviderType.Msp:
var updateMspSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(Plan: PlanType.TeamsMonthly, SeatsMinimum: model.TeamsMonthlySeatMinimum),
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
]);
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
break;
case ProviderType.MultiOrganizationEnterprise:
{ {
new () { ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = model.TeamsMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }, var existingMoePlan = providerPlans.Single();
new () { ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = model.EnterpriseMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }
};
foreach (var newProviderPlan in newProviderPlans) // 1. Change the plan and take over any old values.
{ var changeMoePlanCommand = new ChangeProviderPlanCommand(
await _providerPlanRepository.CreateAsync(newProviderPlan); existingMoePlan.Id,
model.Plan!.Value,
provider.GatewaySubscriptionId);
await _providerBillingService.ChangePlan(changeMoePlanCommand);
// 2. Update the seat minimums.
var updateMoeSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(Plan: model.Plan!.Value, SeatsMinimum: model.EnterpriseMinimumSeats!.Value)
]);
await _providerBillingService.UpdateSeatMinimums(updateMoeSeatMinimumsCommand);
break;
} }
} }
else
{
await _providerBillingService.UpdateSeatMinimums(
provider,
model.EnterpriseMonthlySeatMinimum,
model.TeamsMonthlySeatMinimum);
}
return RedirectToAction("Edit", new { id }); return RedirectToAction("Edit", new { id });
} }

View File

@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models;
public class CreateMspProviderModel : IValidatableObject
{
[Display(Name = "Owner Email")]
public string OwnerEmail { get; set; }
[Display(Name = "Teams (Monthly) Seat Minimum")]
public int TeamsMonthlySeatMinimum { get; set; }
[Display(Name = "Enterprise (Monthly) Seat Minimum")]
public int EnterpriseMonthlySeatMinimum { get; set; }
public virtual Provider ToProvider()
{
return new Provider
{
Type = ProviderType.Msp
};
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(OwnerEmail))
{
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(OwnerEmail);
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
}
if (TeamsMonthlySeatMinimum < 0)
{
var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(TeamsMonthlySeatMinimum);
yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative.");
}
if (EnterpriseMonthlySeatMinimum < 0)
{
var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute<CreateMspProviderModel>()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum);
yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative.");
}
}
}

View File

@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models;
public class CreateMultiOrganizationEnterpriseProviderModel : IValidatableObject
{
[Display(Name = "Owner Email")]
public string OwnerEmail { get; set; }
[Display(Name = "Enterprise Seat Minimum")]
public int EnterpriseSeatMinimum { get; set; }
[Display(Name = "Plan")]
[Required]
public PlanType? Plan { get; set; }
public virtual Provider ToProvider()
{
return new Provider
{
Type = ProviderType.MultiOrganizationEnterprise
};
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(OwnerEmail))
{
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(OwnerEmail);
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
}
if (EnterpriseSeatMinimum < 0)
{
var enterpriseSeatMinimumDisplayName = nameof(EnterpriseSeatMinimum).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(EnterpriseSeatMinimum);
yield return new ValidationResult($"The {enterpriseSeatMinimumDisplayName} field can not be negative.");
}
if (Plan != PlanType.EnterpriseAnnually && Plan != PlanType.EnterpriseMonthly)
{
var planDisplayName = nameof(Plan).GetDisplayAttribute<CreateMultiOrganizationEnterpriseProviderModel>()?.GetName() ?? nameof(Plan);
yield return new ValidationResult($"The {planDisplayName} field must be set to Enterprise Annually or Enterprise Monthly.");
}
}
}

View File

@ -1,84 +1,8 @@
using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models; namespace Bit.Admin.AdminConsole.Models;
public class CreateProviderModel : IValidatableObject public class CreateProviderModel
{ {
public CreateProviderModel() { }
[Display(Name = "Provider Type")]
public ProviderType Type { get; set; } public ProviderType Type { get; set; }
[Display(Name = "Owner Email")]
public string OwnerEmail { get; set; }
[Display(Name = "Name")]
public string Name { get; set; }
[Display(Name = "Business Name")]
public string BusinessName { get; set; }
[Display(Name = "Primary Billing Email")]
public string BillingEmail { get; set; }
[Display(Name = "Teams (Monthly) Seat Minimum")]
public int TeamsMonthlySeatMinimum { get; set; }
[Display(Name = "Enterprise (Monthly) Seat Minimum")]
public int EnterpriseMonthlySeatMinimum { get; set; }
public virtual Provider ToProvider()
{
return new Provider()
{
Type = Type,
Name = Name,
BusinessName = BusinessName,
BillingEmail = BillingEmail?.ToLowerInvariant().Trim()
};
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
switch (Type)
{
case ProviderType.Msp:
if (string.IsNullOrWhiteSpace(OwnerEmail))
{
var ownerEmailDisplayName = nameof(OwnerEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(OwnerEmail);
yield return new ValidationResult($"The {ownerEmailDisplayName} field is required.");
}
if (TeamsMonthlySeatMinimum < 0)
{
var teamsMinimumSeatsDisplayName = nameof(TeamsMonthlySeatMinimum).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(TeamsMonthlySeatMinimum);
yield return new ValidationResult($"The {teamsMinimumSeatsDisplayName} field can not be negative.");
}
if (EnterpriseMonthlySeatMinimum < 0)
{
var enterpriseMinimumSeatsDisplayName = nameof(EnterpriseMonthlySeatMinimum).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMonthlySeatMinimum);
yield return new ValidationResult($"The {enterpriseMinimumSeatsDisplayName} field can not be negative.");
}
break;
case ProviderType.Reseller:
if (string.IsNullOrWhiteSpace(Name))
{
var nameDisplayName = nameof(Name).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Name);
yield return new ValidationResult($"The {nameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BusinessName))
{
var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BusinessName);
yield return new ValidationResult($"The {businessNameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BillingEmail))
{
var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BillingEmail);
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
}
break;
}
}
} }

View File

@ -0,0 +1,48 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities;
namespace Bit.Admin.AdminConsole.Models;
public class CreateResellerProviderModel : IValidatableObject
{
[Display(Name = "Name")]
public string Name { get; set; }
[Display(Name = "Business Name")]
public string BusinessName { get; set; }
[Display(Name = "Primary Billing Email")]
public string BillingEmail { get; set; }
public virtual Provider ToProvider()
{
return new Provider
{
Name = Name,
BusinessName = BusinessName,
BillingEmail = BillingEmail?.ToLowerInvariant().Trim(),
Type = ProviderType.Reseller
};
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(Name))
{
var nameDisplayName = nameof(Name).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Name);
yield return new ValidationResult($"The {nameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BusinessName))
{
var businessNameDisplayName = nameof(BusinessName).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BusinessName);
yield return new ValidationResult($"The {businessNameDisplayName} field is required.");
}
if (string.IsNullOrWhiteSpace(BillingEmail))
{
var billingEmailDisplayName = nameof(BillingEmail).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(BillingEmail);
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
}
}
}

View File

@ -33,6 +33,13 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
GatewayCustomerUrl = gatewayCustomerUrl; GatewayCustomerUrl = gatewayCustomerUrl;
GatewaySubscriptionUrl = gatewaySubscriptionUrl; GatewaySubscriptionUrl = gatewaySubscriptionUrl;
Type = provider.Type; Type = provider.Type;
if (Type == ProviderType.MultiOrganizationEnterprise)
{
var plan = providerPlans.SingleOrDefault();
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
Plan = plan?.PlanType;
}
} }
[Display(Name = "Billing Email")] [Display(Name = "Billing Email")]
@ -58,13 +65,24 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
[Display(Name = "Provider Type")] [Display(Name = "Provider Type")]
public ProviderType Type { get; set; } public ProviderType Type { get; set; }
[Display(Name = "Plan")]
public PlanType? Plan { get; set; }
[Display(Name = "Enterprise Seats Minimum")]
public int? EnterpriseMinimumSeats { get; set; }
public virtual Provider ToProvider(Provider existingProvider) public virtual Provider ToProvider(Provider existingProvider)
{ {
existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim(); existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim();
existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim(); existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim();
switch (Type)
{
case ProviderType.Msp:
existingProvider.Gateway = Gateway; existingProvider.Gateway = Gateway;
existingProvider.GatewayCustomerId = GatewayCustomerId; existingProvider.GatewayCustomerId = GatewayCustomerId;
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId; existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
break;
}
return existingProvider; return existingProvider;
} }
@ -82,6 +100,23 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
yield return new ValidationResult($"The {billingEmailDisplayName} field is required."); yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
} }
break; break;
case ProviderType.MultiOrganizationEnterprise:
if (Plan == null)
{
var displayName = nameof(Plan).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(Plan);
yield return new ValidationResult($"The {displayName} field is required.");
}
if (EnterpriseMinimumSeats == null)
{
var displayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMinimumSeats);
yield return new ValidationResult($"The {displayName} field is required.");
}
if (EnterpriseMinimumSeats < 0)
{
var displayName = nameof(EnterpriseMinimumSeats).GetDisplayAttribute<CreateProviderModel>()?.GetName() ?? nameof(EnterpriseMinimumSeats);
yield return new ValidationResult($"The {displayName} field cannot be less than 0.");
}
break;
} }
} }
} }

View File

@ -1,80 +1,48 @@
@using Bit.SharedWeb.Utilities @using Bit.SharedWeb.Utilities
@using Bit.Core.AdminConsole.Enums.Provider @using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core @using Bit.Core
@model CreateProviderModel @model CreateProviderModel
@inject Bit.Core.Services.IFeatureService FeatureService @inject Bit.Core.Services.IFeatureService FeatureService
@{ @{
ViewData["Title"] = "Create Provider"; ViewData["Title"] = "Create Provider";
}
@section Scripts { var providerTypes = Enum.GetValues<ProviderType>()
<script> .OrderBy(x => x.GetDisplayAttribute().Order)
function toggleProviderTypeInfo(value) { .ToList();
document.querySelectorAll('[id^="info-"]').forEach(el => { el.classList.add('d-none'); });
document.getElementById('info-' + value).classList.remove('d-none'); if (!FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
{
providerTypes.Remove(ProviderType.MultiOrganizationEnterprise);
} }
</script>
} }
<h1>Create Provider</h1> <h1>Create Provider</h1>
<form method="post" asp-action="Create">
<form method="post">
<div asp-validation-summary="All" class="alert alert-danger"></div> <div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group"> <div class="form-group">
<label asp-for="Type" class="h2"></label> <label asp-for="Type" class="h2"></label>
@foreach(ProviderType providerType in Enum.GetValues(typeof(ProviderType))) @foreach (var providerType in providerTypes)
{ {
var providerTypeValue = (int)providerType; var providerTypeValue = (int)providerType;
<div class="form-check">
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input", onclick=$"toggleProviderTypeInfo({providerTypeValue})" })
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" })
<br/>
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted ml-3 align-top", @for = $"providerType-{providerTypeValue}" })
</div>
}
</div>
<div id="@($"info-{(int)ProviderType.Msp}")" class="form-group @(Model.Type != ProviderType.Msp ? "d-none" : string.Empty)">
<h2>MSP Info</h2>
<div class="form-group"> <div class="form-group">
<label asp-for="OwnerEmail"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
</div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col">
<div class="form-group"> <div class="form-check">
<label asp-for="TeamsMonthlySeatMinimum"></label> @Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input" })
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum"> @Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" })
</div> </div>
</div> </div>
<div class="col-sm"> </div>
<div class="form-group"> <div class="row">
<label asp-for="EnterpriseMonthlySeatMinimum"></label> <div class="col">
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum"> @Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetDescription(), new { @class = "form-check-label small text-muted align-top", @for = $"providerType-{providerTypeValue}" })
</div> </div>
</div> </div>
</div> </div>
} }
</div> </div>
<button type="submit" class="btn btn-primary mb-2">Next</button>
<div id="@($"info-{(int)ProviderType.Reseller}")" class="form-group @(Model.Type != ProviderType.Reseller ? "d-none" : string.Empty)">
<h2>Reseller Info</h2>
<div class="form-group">
<label asp-for="Name"></label>
<input type="text" class="form-control" asp-for="Name">
</div>
<div class="form-group">
<label asp-for="BusinessName"></label>
<input type="text" class="form-control" asp-for="BusinessName">
</div>
<div class="form-group">
<label asp-for="BillingEmail"></label>
<input type="text" class="form-control" asp-for="BillingEmail">
</div>
</div>
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
</form> </form>

View File

@ -0,0 +1,39 @@
@using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core
@model CreateMspProviderModel
@inject Bit.Core.Services.IFeatureService FeatureService
@{
ViewData["Title"] = "Create Managed Service Provider";
}
<h1>Create Managed Service Provider</h1>
<div>
<form class="form-group" method="post" asp-action="CreateMsp">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group">
<label asp-for="OwnerEmail"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
</div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="TeamsMonthlySeatMinimum"></label>
<input type="number" class="form-control" asp-for="TeamsMonthlySeatMinimum">
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="EnterpriseMonthlySeatMinimum"></label>
<input type="number" class="form-control" asp-for="EnterpriseMonthlySeatMinimum">
</div>
</div>
</div>
}
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
</form>
</div>

View File

@ -0,0 +1,43 @@
@using Bit.Core.Billing.Enums
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model CreateMultiOrganizationEnterpriseProviderModel
@{
ViewData["Title"] = "Create Multi-organization Enterprise Provider";
}
<h1>Create Multi-organization Enterprise Provider</h1>
<div>
<form class="form-group" method="post" asp-action="CreateMultiOrganizationEnterprise">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group">
<label asp-for="OwnerEmail"></label>
<input type="text" class="form-control" asp-for="OwnerEmail">
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
@{
var multiOrgPlans = new List<PlanType>
{
PlanType.EnterpriseAnnually,
PlanType.EnterpriseMonthly
};
}
<label asp-for="Plan"></label>
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
<option value="">--</option>
</select>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="EnterpriseSeatMinimum"></label>
<input type="number" class="form-control" asp-for="EnterpriseSeatMinimum">
</div>
</div>
</div>
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
</form>
</div>

View File

@ -0,0 +1,25 @@
@model CreateResellerProviderModel
@{
ViewData["Title"] = "Create Reseller Provider";
}
<h1>Create Reseller Provider</h1>
<div>
<form class="form-group" method="post" asp-action="CreateReseller">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group">
<label asp-for="Name"></label>
<input type="text" class="form-control" asp-for="Name">
</div>
<div class="form-group">
<label asp-for="BusinessName"></label>
<input type="text" class="form-control" asp-for="BusinessName">
</div>
<div class="form-group">
<label asp-for="BillingEmail"></label>
<input type="text" class="form-control" asp-for="BillingEmail">
</div>
<button type="submit" class="btn btn-primary mb-2">Create Provider</button>
</form>
</div>

View File

@ -1,6 +1,9 @@
@using Bit.Admin.Enums; @using Bit.Admin.Enums;
@using Bit.Core @using Bit.Core
@using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core.Billing.Enums
@using Bit.Core.Billing.Extensions @using Bit.Core.Billing.Extensions
@using Microsoft.AspNetCore.Mvc.TagHelpers
@inject Bit.Admin.Services.IAccessControlService AccessControlService @inject Bit.Admin.Services.IAccessControlService AccessControlService
@inject Bit.Core.Services.IFeatureService FeatureService @inject Bit.Core.Services.IFeatureService FeatureService
@ -46,6 +49,10 @@
</div> </div>
</div> </div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable()) @if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable())
{
switch (Model.Provider.Type)
{
case ProviderType.Msp:
{ {
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
@ -101,6 +108,39 @@
</div> </div>
</div> </div>
</div> </div>
break;
}
case ProviderType.MultiOrganizationEnterprise:
{
@if (FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises) && Model.Provider.Type == ProviderType.MultiOrganizationEnterprise)
{
<div class="row">
<div class="col-sm">
<div class="form-group">
@{
var multiOrgPlans = new List<PlanType>
{
PlanType.EnterpriseAnnually,
PlanType.EnterpriseMonthly
};
}
<label asp-for="Plan"></label>
<select class="form-control" asp-for="Plan" asp-items="Html.GetEnumSelectList(multiOrgPlans)">
<option value="">--</option>
</select>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="EnterpriseMinimumSeats"></label>
<input type="number" class="form-control" asp-for="EnterpriseMinimumSeats">
</div>
</div>
</div>
}
break;
}
}
} }
</form> </form>
@await Html.PartialAsync("Organizations", Model) @await Html.PartialAsync("Organizations", Model)

View File

@ -4,6 +4,7 @@ using Bit.Admin.Enums;
using Bit.Admin.Models; using Bit.Admin.Models;
using Bit.Admin.Services; using Bit.Admin.Services;
using Bit.Admin.Utilities; using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -24,6 +25,8 @@ public class UsersController : Controller
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IAccessControlService _accessControlService; private readonly IAccessControlService _accessControlService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IUserService _userService;
private readonly IFeatureService _featureService;
public UsersController( public UsersController(
IUserRepository userRepository, IUserRepository userRepository,
@ -31,7 +34,9 @@ public class UsersController : Controller
IPaymentService paymentService, IPaymentService paymentService,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IAccessControlService accessControlService, IAccessControlService accessControlService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserService userService,
IFeatureService featureService)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_cipherRepository = cipherRepository; _cipherRepository = cipherRepository;
@ -39,6 +44,8 @@ public class UsersController : Controller
_globalSettings = globalSettings; _globalSettings = globalSettings;
_accessControlService = accessControlService; _accessControlService = accessControlService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_userService = userService;
_featureService = featureService;
} }
[RequirePermission(Permission.User_List_View)] [RequirePermission(Permission.User_List_View)]
@ -82,8 +89,8 @@ public class UsersController : Controller
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id); var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var verifiedDomain = await AccountDeprovisioningEnabled(user.Id);
return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers)); return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, verifiedDomain));
} }
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
@ -99,7 +106,8 @@ public class UsersController : Controller
var billingInfo = await _paymentService.GetBillingAsync(user); var billingInfo = await _paymentService.GetBillingAsync(user);
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user); var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(user);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user); var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings)); var verifiedDomain = await AccountDeprovisioningEnabled(user.Id);
return View(new UserEditModel(user, isTwoFactorEnabled, ciphers, billingInfo, billingHistoryInfo, _globalSettings, verifiedDomain));
} }
[HttpPost] [HttpPost]
@ -153,4 +161,12 @@ public class UsersController : Controller
return RedirectToAction("Index"); return RedirectToAction("Index");
} }
// TODO: Feature flag to be removed in PM-14207
private async Task<bool?> AccountDeprovisioningEnabled(Guid userId)
{
return _featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
? await _userService.IsManagedByAnyOrganizationAsync(userId)
: null;
}
} }

View File

@ -0,0 +1,19 @@

using Bit.SharedWeb.Utilities;
// ReSharper disable once CheckNamespace
namespace Microsoft.AspNetCore.Mvc.Rendering;
public static class HtmlHelper
{
public static IEnumerable<SelectListItem> GetEnumSelectList<T>(this IHtmlHelper htmlHelper, IEnumerable<T> values)
where T : Enum
{
return values.Select(v => new SelectListItem
{
Text = v.GetDisplayAttribute().Name,
Value = v.ToString()
});
}
}

View File

@ -20,9 +20,11 @@ public class UserEditModel
IEnumerable<Cipher> ciphers, IEnumerable<Cipher> ciphers,
BillingInfo billingInfo, BillingInfo billingInfo,
BillingHistoryInfo billingHistoryInfo, BillingHistoryInfo billingHistoryInfo,
GlobalSettings globalSettings) GlobalSettings globalSettings,
bool? domainVerified
)
{ {
User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers); User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, domainVerified);
BillingInfo = billingInfo; BillingInfo = billingInfo;
BillingHistoryInfo = billingHistoryInfo; BillingHistoryInfo = billingHistoryInfo;

View File

@ -14,6 +14,7 @@ public class UserViewModel
public bool Premium { get; } public bool Premium { get; }
public short? MaxStorageGb { get; } public short? MaxStorageGb { get; }
public bool EmailVerified { get; } public bool EmailVerified { get; }
public bool? DomainVerified { get; }
public bool TwoFactorEnabled { get; } public bool TwoFactorEnabled { get; }
public DateTime AccountRevisionDate { get; } public DateTime AccountRevisionDate { get; }
public DateTime RevisionDate { get; } public DateTime RevisionDate { get; }
@ -35,6 +36,7 @@ public class UserViewModel
bool premium, bool premium,
short? maxStorageGb, short? maxStorageGb,
bool emailVerified, bool emailVerified,
bool? domainVerified,
bool twoFactorEnabled, bool twoFactorEnabled,
DateTime accountRevisionDate, DateTime accountRevisionDate,
DateTime revisionDate, DateTime revisionDate,
@ -56,6 +58,7 @@ public class UserViewModel
Premium = premium; Premium = premium;
MaxStorageGb = maxStorageGb; MaxStorageGb = maxStorageGb;
EmailVerified = emailVerified; EmailVerified = emailVerified;
DomainVerified = domainVerified;
TwoFactorEnabled = twoFactorEnabled; TwoFactorEnabled = twoFactorEnabled;
AccountRevisionDate = accountRevisionDate; AccountRevisionDate = accountRevisionDate;
RevisionDate = revisionDate; RevisionDate = revisionDate;
@ -73,10 +76,10 @@ public class UserViewModel
public static IEnumerable<UserViewModel> MapViewModels( public static IEnumerable<UserViewModel> MapViewModels(
IEnumerable<User> users, IEnumerable<User> users,
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) => IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) =>
users.Select(user => MapViewModel(user, lookup)); users.Select(user => MapViewModel(user, lookup, false));
public static UserViewModel MapViewModel(User user, public static UserViewModel MapViewModel(User user,
IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup) => IEnumerable<(Guid userId, bool twoFactorIsEnabled)> lookup, bool? domainVerified) =>
new( new(
user.Id, user.Id,
user.Name, user.Name,
@ -86,6 +89,7 @@ public class UserViewModel
user.Premium, user.Premium,
user.MaxStorageGb, user.MaxStorageGb,
user.EmailVerified, user.EmailVerified,
domainVerified,
IsTwoFactorEnabled(user, lookup), IsTwoFactorEnabled(user, lookup),
user.AccountRevisionDate, user.AccountRevisionDate,
user.RevisionDate, user.RevisionDate,
@ -100,9 +104,9 @@ public class UserViewModel
Array.Empty<Cipher>()); Array.Empty<Cipher>());
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled) => public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled) =>
MapViewModel(user, isTwoFactorEnabled, Array.Empty<Cipher>()); MapViewModel(user, isTwoFactorEnabled, Array.Empty<Cipher>(), false);
public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled, IEnumerable<Cipher> ciphers) => public static UserViewModel MapViewModel(User user, bool isTwoFactorEnabled, IEnumerable<Cipher> ciphers, bool? domainVerified) =>
new( new(
user.Id, user.Id,
user.Name, user.Name,
@ -112,6 +116,7 @@ public class UserViewModel
user.Premium, user.Premium,
user.MaxStorageGb, user.MaxStorageGb,
user.EmailVerified, user.EmailVerified,
domainVerified,
isTwoFactorEnabled, isTwoFactorEnabled,
user.AccountRevisionDate, user.AccountRevisionDate,
user.RevisionDate, user.RevisionDate,

View File

@ -110,6 +110,7 @@ public static class RolePermissionMapping
Permission.User_Licensing_View, Permission.User_Licensing_View,
Permission.User_Billing_View, Permission.User_Billing_View,
Permission.User_Billing_LaunchGateway, Permission.User_Billing_LaunchGateway,
Permission.User_Delete,
Permission.Org_List_View, Permission.Org_List_View,
Permission.Org_OrgInformation_View, Permission.Org_OrgInformation_View,
Permission.Org_GeneralDetails_View, Permission.Org_GeneralDetails_View,

View File

@ -1,4 +1,4 @@
@model UserViewModel @model UserViewModel
<dl class="row"> <dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt> <dt class="col-sm-4 col-lg-3">Id</dt>
<dd class="col-sm-8 col-lg-9"><code>@Model.Id</code></dd> <dd class="col-sm-8 col-lg-9"><code>@Model.Id</code></dd>
@ -12,6 +12,11 @@
<dt class="col-sm-4 col-lg-3">Email Verified</dt> <dt class="col-sm-4 col-lg-3">Email Verified</dt>
<dd class="col-sm-8 col-lg-9">@(Model.EmailVerified ? "Yes" : "No")</dd> <dd class="col-sm-8 col-lg-9">@(Model.EmailVerified ? "Yes" : "No")</dd>
@if(Model.DomainVerified.HasValue){
<dt class="col-sm-4 col-lg-3">Domain Verified</dt>
<dd class="col-sm-8 col-lg-9">@(Model.DomainVerified.Value == true ? "Yes" : "No")</dd>
}
<dt class="col-sm-4 col-lg-3">Using 2FA</dt> <dt class="col-sm-4 col-lg-3">Using 2FA</dt>
<dd class="col-sm-8 col-lg-9">@(Model.TwoFactorEnabled ? "Yes" : "No")</dd> <dd class="col-sm-8 col-lg-9">@(Model.TwoFactorEnabled ? "Yes" : "No")</dd>

View File

@ -1,6 +1,5 @@
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations; using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Api.Vault.AuthorizationHandlers.Collections;
@ -53,6 +52,8 @@ public class OrganizationUsersController : Controller
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand; private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
private readonly IFeatureService _featureService;
public OrganizationUsersController( public OrganizationUsersController(
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
@ -73,7 +74,9 @@ public class OrganizationUsersController : Controller
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand, IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand) IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
IFeatureService featureService)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@ -94,29 +97,34 @@ public class OrganizationUsersController : Controller
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand; _deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
_featureService = featureService;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
public async Task<OrganizationUserDetailsResponseModel> Get(string id, bool includeGroups = false) public async Task<OrganizationUserDetailsResponseModel> Get(Guid id, bool includeGroups = false)
{ {
var organizationUser = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(new Guid(id)); var (organizationUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
if (organizationUser == null || !await _currentContext.ManageUsers(organizationUser.Item1.OrganizationId)) if (organizationUser == null || !await _currentContext.ManageUsers(organizationUser.OrganizationId))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var response = new OrganizationUserDetailsResponseModel(organizationUser.Item1, organizationUser.Item2); var managedByOrganization = await GetManagedByOrganizationStatusAsync(
organizationUser.OrganizationId,
[organizationUser.Id]);
var response = new OrganizationUserDetailsResponseModel(organizationUser, managedByOrganization[organizationUser.Id], collections);
if (includeGroups) if (includeGroups)
{ {
response.Groups = await _groupRepository.GetManyIdsByUserIdAsync(organizationUser.Item1.Id); response.Groups = await _groupRepository.GetManyIdsByUserIdAsync(organizationUser.Id);
} }
return response; return response;
} }
[HttpGet("mini-details")] [HttpGet("mini-details")]
[RequireFeature(FeatureFlagKeys.Pm3478RefactorOrganizationUserApi)]
public async Task<ListResponseModel<OrganizationUserUserMiniDetailsResponseModel>> GetMiniDetails(Guid orgId) public async Task<ListResponseModel<OrganizationUserUserMiniDetailsResponseModel>> GetMiniDetails(Guid orgId)
{ {
var authorizationResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId), var authorizationResult = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(orgId),
@ -150,11 +158,13 @@ public class OrganizationUsersController : Controller
} }
); );
var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers); var organizationUsersTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUsers);
var organizationUsersManagementStatus = await GetManagedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id));
var responses = organizationUsers var responses = organizationUsers
.Select(o => .Select(o =>
{ {
var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled; var userTwoFactorEnabled = organizationUsersTwoFactorEnabled.FirstOrDefault(u => u.user.Id == o.Id).twoFactorIsEnabled;
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled); var managedByOrganization = organizationUsersManagementStatus[o.Id];
var orgUser = new OrganizationUserUserDetailsResponseModel(o, userTwoFactorEnabled, managedByOrganization);
return orgUser; return orgUser;
}); });
@ -534,7 +544,7 @@ public class OrganizationUsersController : Controller
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("{id}/delete-account")] [HttpDelete("{id}/delete-account")]
[HttpPost("{id}/delete-account")] [HttpPost("{id}/delete-account")]
public async Task DeleteAccount(Guid orgId, Guid id, [FromBody] SecretVerificationRequestModel model) public async Task DeleteAccount(Guid orgId, Guid id)
{ {
if (!await _currentContext.ManageUsers(orgId)) if (!await _currentContext.ManageUsers(orgId))
{ {
@ -547,19 +557,13 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
if (!await _userService.VerifySecretAsync(currentUser, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); await _deleteManagedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id);
} }
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)] [RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("delete-account")] [HttpDelete("delete-account")]
[HttpPost("delete-account")] [HttpPost("delete-account")]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] SecureOrganizationUserBulkRequestModel model) public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{ {
if (!await _currentContext.ManageUsers(orgId)) if (!await _currentContext.ManageUsers(orgId))
{ {
@ -572,12 +576,6 @@ public class OrganizationUsersController : Controller
throw new UnauthorizedAccessException(); throw new UnauthorizedAccessException();
} }
if (!await _userService.VerifySecretAsync(currentUser, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException(string.Empty, "User verification failed.");
}
var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); var results = await _deleteManagedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id);
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r => return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
@ -682,4 +680,15 @@ public class OrganizationUsersController : Controller
return new ListResponseModel<OrganizationUserBulkResponseModel>(result.Select(r => return new ListResponseModel<OrganizationUserBulkResponseModel>(result.Select(r =>
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2))); new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
} }
private async Task<IDictionary<Guid, bool>> GetManagedByOrganizationStatusAsync(Guid orgId, IEnumerable<Guid> userIds)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{
return userIds.ToDictionary(kvp => kvp, kvp => false);
}
var usersOrganizationManagementStatus = await _getOrganizationUsersManagementStatusQuery.GetUsersOrganizationManagementStatusAsync(orgId, userIds);
return usersOrganizationManagementStatus;
}
} }

View File

@ -16,6 +16,7 @@ using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using AdminConsoleEntities = Bit.Core.AdminConsole.Entities;
namespace Bit.Api.AdminConsole.Controllers; namespace Bit.Api.AdminConsole.Controllers;
@ -55,17 +56,16 @@ public class PoliciesController : Controller
} }
[HttpGet("{type}")] [HttpGet("{type}")]
public async Task<PolicyResponseModel> Get(string orgId, int type) public async Task<PolicyResponseModel> Get(Guid orgId, int type)
{ {
var orgIdGuid = new Guid(orgId); if (!await _currentContext.ManagePolicies(orgId))
if (!await _currentContext.ManagePolicies(orgIdGuid))
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgIdGuid, (PolicyType)type); var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
if (policy == null) if (policy == null)
{ {
throw new NotFoundException(); return new PolicyResponseModel(new AdminConsoleEntities.Policy() { Type = (PolicyType)type, Enabled = false });
} }
return new PolicyResponseModel(policy); return new PolicyResponseModel(policy);

View File

@ -1,10 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Request.Accounts;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class SecureOrganizationUserBulkRequestModel : SecretVerificationRequestModel
{
[Required]
public IEnumerable<Guid> Ids { get; set; }
}

View File

@ -64,20 +64,27 @@ public class OrganizationUserResponseModel : ResponseModel
public class OrganizationUserDetailsResponseModel : OrganizationUserResponseModel public class OrganizationUserDetailsResponseModel : OrganizationUserResponseModel
{ {
public OrganizationUserDetailsResponseModel(OrganizationUser organizationUser, public OrganizationUserDetailsResponseModel(
OrganizationUser organizationUser,
bool managedByOrganization,
IEnumerable<CollectionAccessSelection> collections) IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails") : base(organizationUser, "organizationUserDetails")
{ {
ManagedByOrganization = managedByOrganization;
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
} }
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool managedByOrganization,
IEnumerable<CollectionAccessSelection> collections) IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails") : base(organizationUser, "organizationUserDetails")
{ {
ManagedByOrganization = managedByOrganization;
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c)); Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
} }
public bool ManagedByOrganization { get; set; }
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; } public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
@ -110,7 +117,7 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
{ {
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser, public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool twoFactorEnabled, string obj = "organizationUserUserDetails") bool twoFactorEnabled, bool managedByOrganization, string obj = "organizationUserUserDetails")
: base(organizationUser, obj) : base(organizationUser, obj)
{ {
if (organizationUser == null) if (organizationUser == null)
@ -127,6 +134,7 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
Groups = organizationUser.Groups; Groups = organizationUser.Groups;
// Prevent reset password when using key connector. // Prevent reset password when using key connector.
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector; ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
ManagedByOrganization = managedByOrganization;
} }
public string Name { get; set; } public string Name { get; set; }
@ -134,6 +142,11 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
public string AvatarColor { get; set; } public string AvatarColor { get; set; }
public bool TwoFactorEnabled { get; set; } public bool TwoFactorEnabled { get; set; }
public bool SsoBound { get; set; } public bool SsoBound { get; set; }
/// <summary>
/// Indicates if the organization manages the user. If a user is "managed" by an organization,
/// the organization has greater control over their account, and some user actions are restricted.
/// </summary>
public bool ManagedByOrganization { get; set; }
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; } public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
public IEnumerable<Guid> Groups { get; set; } public IEnumerable<Guid> Groups { get; set; }
} }

View File

@ -71,14 +71,13 @@ public class MembersController : Controller
[ProducesResponseType((int)HttpStatusCode.NotFound)] [ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Get(Guid id) public async Task<IActionResult> Get(Guid id)
{ {
var userDetails = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id); var (orgUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
var orgUser = userDetails?.Item1;
if (orgUser == null || orgUser.OrganizationId != _currentContext.OrganizationId) if (orgUser == null || orgUser.OrganizationId != _currentContext.OrganizationId)
{ {
return new NotFoundResult(); return new NotFoundResult();
} }
var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser), var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser),
userDetails.Item2); collections);
return new JsonResult(response); return new JsonResult(response);
} }

View File

@ -35,7 +35,7 @@
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" /> <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" /> <PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" /> <PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.8.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -148,6 +148,13 @@ public class AccountsController : Controller
throw new BadRequestException("MasterPasswordHash", "Invalid password."); throw new BadRequestException("MasterPasswordHash", "Invalid password.");
} }
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
{
throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.");
}
await _userService.InitiateEmailChangeAsync(user, model.NewEmail); await _userService.InitiateEmailChangeAsync(user, model.NewEmail);
} }
@ -165,6 +172,13 @@ public class AccountsController : Controller
throw new BadRequestException("You cannot change your email when using Key Connector."); throw new BadRequestException("You cannot change your email when using Key Connector.");
} }
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
{
throw new BadRequestException("Cannot change emails for accounts owned by an organization. Contact your organization administrator for additional details.");
}
var result = await _userService.ChangeEmailAsync(user, model.MasterPasswordHash, model.NewEmail, var result = await _userService.ChangeEmailAsync(user, model.MasterPasswordHash, model.NewEmail,
model.NewMasterPasswordHash, model.Token, model.Key); model.NewMasterPasswordHash, model.Token, model.Key);
if (result.Succeeded) if (result.Succeeded)
@ -566,6 +580,13 @@ public class AccountsController : Controller
} }
else else
{ {
// If Account Deprovisioning is enabled, we need to check if the user is managed by any organization.
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _userService.IsManagedByAnyOrganizationAsync(user.Id))
{
throw new BadRequestException("Cannot delete accounts owned by an organization. Contact your organization administrator for additional details.");
}
var result = await _userService.DeleteAsync(user); var result = await _userService.DeleteAsync(user);
if (result.Succeeded) if (result.Succeeded)
{ {

View File

@ -26,7 +26,7 @@ public class OrganizationBillingController(
[HttpGet("metadata")] [HttpGet("metadata")]
public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId) public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId)
{ {
if (!await currentContext.AccessMembersTab(organizationId)) if (!await currentContext.OrganizationUser(organizationId))
{ {
return Error.Unauthorized(); return Error.Unauthorized();
} }

View File

@ -4,10 +4,14 @@ namespace Bit.Api.Billing.Models.Responses;
public record OrganizationMetadataResponse( public record OrganizationMetadataResponse(
bool IsEligibleForSelfHost, bool IsEligibleForSelfHost,
bool IsOnSecretsManagerStandalone) bool IsManaged,
bool IsOnSecretsManagerStandalone,
bool IsSubscriptionUnpaid)
{ {
public static OrganizationMetadataResponse From(OrganizationMetadata metadata) public static OrganizationMetadataResponse From(OrganizationMetadata metadata)
=> new( => new(
metadata.IsEligibleForSelfHost, metadata.IsEligibleForSelfHost,
metadata.IsOnSecretsManagerStandalone); metadata.IsManaged,
metadata.IsOnSecretsManagerStandalone,
metadata.IsSubscriptionUnpaid);
} }

View File

@ -196,8 +196,8 @@ public class DevicesController : Controller
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[HttpPost("{id}/delete")] [HttpPost("{id}/deactivate")]
public async Task Delete(string id) public async Task Deactivate(string id)
{ {
var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value); var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value);
if (device == null) if (device == null)
@ -205,7 +205,7 @@ public class DevicesController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
await _deviceService.DeleteAsync(device); await _deviceService.DeactivateAsync(device);
} }
[AllowAnonymous] [AllowAnonymous]

View File

@ -4,8 +4,10 @@ namespace Bit.Core.AdminConsole.Enums.Provider;
public enum ProviderType : byte public enum ProviderType : byte
{ {
[Display(ShortName = "MSP", Name = "Managed Service Provider", Description = "Access to clients organization")] [Display(ShortName = "MSP", Name = "Managed Service Provider", Description = "Access to clients organization", Order = 0)]
Msp = 0, Msp = 0,
[Display(ShortName = "Reseller", Name = "Reseller", Description = "Access to clients billing")] [Display(ShortName = "Reseller", Name = "Reseller", Description = "Access to clients billing", Order = 1000)]
Reseller = 1, Reseller = 1,
[Display(ShortName = "MOE", Name = "Multi-organization Enterprise", Description = "Access to multiple organizations", Order = 1)]
MultiOrganizationEnterprise = 2,
} }

View File

@ -0,0 +1,6 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
public interface IOrganizationHasVerifiedDomainsQuery
{
Task<bool> HasVerifiedDomainsAsync(Guid orgId);
}

View File

@ -0,0 +1,10 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.Repositories;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains;
public class OrganizationHasVerifiedDomainsQuery(IOrganizationDomainRepository domainRepository) : IOrganizationHasVerifiedDomainsQuery
{
public async Task<bool> HasVerifiedDomainsAsync(Guid orgId) =>
(await domainRepository.GetDomainsByOrganizationIdAsync(orgId)).Any(od => od.VerifiedDate is not null);
}

View File

@ -1,4 +1,7 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@ -15,6 +18,9 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
private readonly IDnsResolverService _dnsResolverService; private readonly IDnsResolverService _dnsResolverService;
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly IPolicyService _policyService;
private readonly IFeatureService _featureService;
private readonly IOrganizationService _organizationService;
private readonly ILogger<VerifyOrganizationDomainCommand> _logger; private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
public VerifyOrganizationDomainCommand( public VerifyOrganizationDomainCommand(
@ -22,12 +28,18 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
IDnsResolverService dnsResolverService, IDnsResolverService dnsResolverService,
IEventService eventService, IEventService eventService,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
IPolicyService policyService,
IFeatureService featureService,
IOrganizationService organizationService,
ILogger<VerifyOrganizationDomainCommand> logger) ILogger<VerifyOrganizationDomainCommand> logger)
{ {
_organizationDomainRepository = organizationDomainRepository; _organizationDomainRepository = organizationDomainRepository;
_dnsResolverService = dnsResolverService; _dnsResolverService = dnsResolverService;
_eventService = eventService; _eventService = eventService;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_policyService = policyService;
_featureService = featureService;
_organizationService = organizationService;
_logger = logger; _logger = logger;
} }
@ -102,6 +114,8 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
if (await _dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt)) if (await _dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt))
{ {
domain.SetVerifiedDate(); domain.SetVerifiedDate();
await EnableSingleOrganizationPolicyAsync(domain.OrganizationId);
} }
} }
catch (Exception e) catch (Exception e)
@ -112,4 +126,13 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
return domain; return domain;
} }
private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId)
{
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning))
{
await _policyService.SaveAsync(
new Policy { OrganizationId = organizationId, Type = PolicyType.SingleOrg, Enabled = true }, null);
}
}
} }

View File

@ -1,7 +1,6 @@
#nullable enable #nullable enable
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
@ -10,12 +9,10 @@ public class OrganizationUserUserDetailsAuthorizationHandler
: AuthorizationHandler<OrganizationUserUserDetailsOperationRequirement, OrganizationScope> : AuthorizationHandler<OrganizationUserUserDetailsOperationRequirement, OrganizationScope>
{ {
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
public OrganizationUserUserDetailsAuthorizationHandler(ICurrentContext currentContext, IFeatureService featureService) public OrganizationUserUserDetailsAuthorizationHandler(ICurrentContext currentContext)
{ {
_currentContext = currentContext; _currentContext = currentContext;
_featureService = featureService;
} }
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
@ -37,29 +34,6 @@ public class OrganizationUserUserDetailsAuthorizationHandler
} }
private async Task<bool> CanReadAllAsync(Guid organizationId) private async Task<bool> CanReadAllAsync(Guid organizationId)
{
if (_featureService.IsEnabled(FeatureFlagKeys.Pm3478RefactorOrganizationUserApi))
{
return await CanReadAllAsync_vNext(organizationId);
}
return await CanReadAllAsync_vCurrent(organizationId);
}
private async Task<bool> CanReadAllAsync_vCurrent(Guid organizationId)
{
// All users of an organization can read all other users of that organization for collection access management
var org = _currentContext.GetOrganization(organizationId);
if (org is not null)
{
return true;
}
// Allow provider users to read all organization users if they are a provider for the target organization
return await _currentContext.ProviderUserForOrgAsync(organizationId);
}
private async Task<bool> CanReadAllAsync_vNext(Guid organizationId)
{ {
// Admins can access this for general user management // Admins can access this for general user management
var organization = _currentContext.GetOrganization(organizationId); var organization = _currentContext.GetOrganization(organizationId);

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Enums;
namespace Bit.Core.AdminConsole.Providers.Interfaces; namespace Bit.Core.AdminConsole.Providers.Interfaces;
@ -6,4 +7,5 @@ public interface ICreateProviderCommand
{ {
Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats); Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats);
Task CreateResellerAsync(Provider provider); Task CreateResellerAsync(Provider provider);
Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats);
} }

View File

@ -22,8 +22,7 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
Task<OrganizationUser?> GetByOrganizationAsync(Guid organizationId, Guid userId); Task<OrganizationUser?> GetByOrganizationAsync(Guid organizationId, Guid userId);
Task<Tuple<OrganizationUser?, ICollection<CollectionAccessSelection>>> GetByIdWithCollectionsAsync(Guid id); Task<Tuple<OrganizationUser?, ICollection<CollectionAccessSelection>>> GetByIdWithCollectionsAsync(Guid id);
Task<OrganizationUserUserDetails?> GetDetailsByIdAsync(Guid id); Task<OrganizationUserUserDetails?> GetDetailsByIdAsync(Guid id);
Task<Tuple<OrganizationUserUserDetails?, ICollection<CollectionAccessSelection>>> Task<(OrganizationUserUserDetails? OrganizationUser, ICollection<CollectionAccessSelection> Collections)> GetDetailsByIdWithCollectionsAsync(Guid id);
GetDetailsByIdWithCollectionsAsync(Guid id);
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false); Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId, Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
OrganizationUserStatusType? status = null); OrganizationUserStatusType? status = null);

View File

@ -4,8 +4,4 @@ public interface IOrganizationDomainService
{ {
Task ValidateOrganizationsDomainAsync(); Task ValidateOrganizationsDomainAsync();
Task OrganizationDomainMaintenanceAsync(); Task OrganizationDomainMaintenanceAsync();
/// <summary>
/// Indicates if the organization has any verified domains.
/// </summary>
Task<bool> HasVerifiedDomainsAsync(Guid orgId);
} }

View File

@ -106,12 +106,6 @@ public class OrganizationDomainService : IOrganizationDomainService
} }
} }
public async Task<bool> HasVerifiedDomainsAsync(Guid orgId)
{
var orgDomains = await _domainRepository.GetDomainsByOrganizationIdAsync(orgId);
return orgDomains.Any(od => od.VerifiedDate != null);
}
private async Task<List<string>> GetAdminEmailsAsync(Guid organizationId) private async Task<List<string>> GetAdminEmailsAsync(Guid organizationId)
{ {
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId); var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);

View File

@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
@ -32,6 +33,7 @@ public class PolicyService : IPolicyService
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly ISavePolicyCommand _savePolicyCommand; private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
public PolicyService( public PolicyService(
IApplicationCacheService applicationCacheService, IApplicationCacheService applicationCacheService,
@ -45,7 +47,8 @@ public class PolicyService : IPolicyService
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IFeatureService featureService, IFeatureService featureService,
ISavePolicyCommand savePolicyCommand, ISavePolicyCommand savePolicyCommand,
IRemoveOrganizationUserCommand removeOrganizationUserCommand) IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
{ {
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_eventService = eventService; _eventService = eventService;
@ -59,6 +62,7 @@ public class PolicyService : IPolicyService
_featureService = featureService; _featureService = featureService;
_savePolicyCommand = savePolicyCommand; _savePolicyCommand = savePolicyCommand;
_removeOrganizationUserCommand = removeOrganizationUserCommand; _removeOrganizationUserCommand = removeOrganizationUserCommand;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
} }
public async Task SaveAsync(Policy policy, Guid? savingUserId) public async Task SaveAsync(Policy policy, Guid? savingUserId)
@ -239,6 +243,7 @@ public class PolicyService : IPolicyService
case PolicyType.SingleOrg: case PolicyType.SingleOrg:
if (!policy.Enabled) if (!policy.Enabled)
{ {
await HasVerifiedDomainsAsync(org);
await RequiredBySsoAsync(org); await RequiredBySsoAsync(org);
await RequiredByVaultTimeoutAsync(org); await RequiredByVaultTimeoutAsync(org);
await RequiredByKeyConnectorAsync(org); await RequiredByKeyConnectorAsync(org);
@ -279,6 +284,15 @@ public class PolicyService : IPolicyService
} }
} }
private async Task HasVerifiedDomainsAsync(Organization org)
{
if (_featureService.IsEnabled(FeatureFlagKeys.AccountDeprovisioning)
&& await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(org.Id))
{
throw new BadRequestException("Organization has verified domains.");
}
}
private async Task SetPolicyConfiguration(Policy policy) private async Task SetPolicyConfiguration(Policy policy)
{ {
await _policyRepository.UpsertAsync(policy); await _policyRepository.UpsertAsync(policy);

View File

@ -6,6 +6,14 @@ using Bit.Core.Utilities;
namespace Bit.Core.Auth.Models.Api.Request.Accounts; namespace Bit.Core.Auth.Models.Api.Request.Accounts;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
public enum RegisterFinishTokenType : byte
{
EmailVerification = 1,
OrganizationInvite = 2,
OrgSponsoredFreeFamilyPlan = 3,
EmergencyAccessInvite = 4,
ProviderInvite = 5,
}
public class RegisterFinishRequestModel : IValidatableObject public class RegisterFinishRequestModel : IValidatableObject
{ {
@ -36,6 +44,10 @@ public class RegisterFinishRequestModel : IValidatableObject
public string? AcceptEmergencyAccessInviteToken { get; set; } public string? AcceptEmergencyAccessInviteToken { get; set; }
public Guid? AcceptEmergencyAccessId { get; set; } public Guid? AcceptEmergencyAccessId { get; set; }
public string? ProviderInviteToken { get; set; }
public Guid? ProviderUserId { get; set; }
public User ToUser() public User ToUser()
{ {
var user = new User var user = new User
@ -54,6 +66,32 @@ public class RegisterFinishRequestModel : IValidatableObject
return user; return user;
} }
public RegisterFinishTokenType GetTokenType()
{
if (!string.IsNullOrWhiteSpace(EmailVerificationToken))
{
return RegisterFinishTokenType.EmailVerification;
}
if (!string.IsNullOrEmpty(OrgInviteToken) && OrganizationUserId.HasValue)
{
return RegisterFinishTokenType.OrganizationInvite;
}
if (!string.IsNullOrWhiteSpace(OrgSponsoredFreeFamilyPlanToken))
{
return RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan;
}
if (!string.IsNullOrWhiteSpace(AcceptEmergencyAccessInviteToken) && AcceptEmergencyAccessId.HasValue)
{
return RegisterFinishTokenType.EmergencyAccessInvite;
}
if (!string.IsNullOrWhiteSpace(ProviderInviteToken) && ProviderUserId.HasValue)
{
return RegisterFinishTokenType.ProviderInvite;
}
throw new InvalidOperationException("Invalid token type.");
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{ {

View File

@ -0,0 +1,7 @@
using Bit.Core.Models.Mail;
namespace Bit.Core.Auth.Models.Mail;
public class CannotDeleteManagedAccountViewModel : BaseMailModel
{
}

View File

@ -61,4 +61,16 @@ public interface IRegisterUserCommand
public Task<IdentityResult> RegisterUserViaAcceptEmergencyAccessInviteToken(User user, string masterPasswordHash, public Task<IdentityResult> RegisterUserViaAcceptEmergencyAccessInviteToken(User user, string masterPasswordHash,
string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId); string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId);
/// <summary>
/// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event.
/// If a valid token is provided, the user will be created with their email verified.
/// If the token is invalid or expired, an error will be thrown.
/// </summary>
/// <param name="user">The <see cref="User"/> to create</param>
/// <param name="masterPasswordHash">The hashed master password the user entered</param>
/// <param name="providerInviteToken">The provider invite token sent to the user via email</param>
/// <param name="providerUserId">The provider user id which is used to validate the invite token</param>
/// <returns><see cref="IdentityResult"/></returns>
public Task<IdentityResult> RegisterUserViaProviderInviteToken(User user, string masterPasswordHash, string providerInviteToken, Guid providerUserId);
} }

View File

@ -32,6 +32,7 @@ public class RegisterUserCommand : IRegisterUserCommand
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory; private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory; private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
private readonly IDataProtector _organizationServiceDataProtector; private readonly IDataProtector _organizationServiceDataProtector;
private readonly IDataProtector _providerServiceDataProtector;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
@ -75,6 +76,8 @@ public class RegisterUserCommand : IRegisterUserCommand
_validateRedemptionTokenCommand = validateRedemptionTokenCommand; _validateRedemptionTokenCommand = validateRedemptionTokenCommand;
_emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory; _emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory;
_providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
} }
@ -303,6 +306,25 @@ public class RegisterUserCommand : IRegisterUserCommand
return result; return result;
} }
public async Task<IdentityResult> RegisterUserViaProviderInviteToken(User user, string masterPasswordHash,
string providerInviteToken, Guid providerUserId)
{
ValidateOpenRegistrationAllowed();
ValidateProviderInviteToken(providerInviteToken, providerUserId, user.Email);
user.EmailVerified = true;
user.ApiKey = CoreHelpers.SecureRandomString(30); // API key can't be null.
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success)
{
await _mailService.SendWelcomeEmailAsync(user);
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(ReferenceEventType.Signup, user, _currentContext));
}
return result;
}
private void ValidateOpenRegistrationAllowed() private void ValidateOpenRegistrationAllowed()
{ {
// We validate open registration on send of initial email and here b/c a user could technically start the // We validate open registration on send of initial email and here b/c a user could technically start the
@ -333,6 +355,15 @@ public class RegisterUserCommand : IRegisterUserCommand
} }
} }
private void ValidateProviderInviteToken(string providerInviteToken, Guid providerUserId, string userEmail)
{
if (!CoreHelpers.TokenIsValid("ProviderUserInvite", _providerServiceDataProtector, providerInviteToken, userEmail, providerUserId,
_globalSettings.OrganizationInviteExpirationHours))
{
throw new BadRequestException("Invalid provider invite token.");
}
}
private RegistrationEmailVerificationTokenable ValidateRegistrationEmailVerificationTokenable(string emailVerificationToken, string userEmail) private RegistrationEmailVerificationTokenable ValidateRegistrationEmailVerificationTokenable(string emailVerificationToken, string userEmail)
{ {

View File

@ -11,11 +11,10 @@ namespace Bit.Core.Billing.Extensions;
public static class BillingExtensions public static class BillingExtensions
{ {
public static bool IsBillable(this Provider provider) => public static bool IsBillable(this Provider provider) =>
provider is provider.SupportsConsolidatedBilling() && provider.Status == ProviderStatusType.Billable;
{
Type: ProviderType.Msp, public static bool SupportsConsolidatedBilling(this Provider provider)
Status: ProviderStatusType.Billable => provider.Type is ProviderType.Msp or ProviderType.MultiOrganizationEnterprise;
};
public static bool IsValidClient(this Organization organization) public static bool IsValidClient(this Organization organization)
=> organization is => organization is

View File

@ -8,6 +8,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Migration.Models; using Bit.Core.Billing.Migration.Models;
using Bit.Core.Billing.Repositories; using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -307,7 +308,14 @@ public class ProviderMigrator(
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)? .FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)?
.SeatMinimum ?? 0; .SeatMinimum ?? 0;
await providerBillingService.UpdateSeatMinimums(provider, enterpriseSeatMinimum, teamsSeatMinimum); var updateSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand(
provider.Id,
provider.GatewaySubscriptionId,
[
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: enterpriseSeatMinimum),
(Plan: PlanType.TeamsMonthly, SeatsMinimum: teamsSeatMinimum)
]);
await providerBillingService.UpdateSeatMinimums(updateSeatMinimumsCommand);
logger.LogInformation( logger.LogInformation(
"CB: Updated Stripe subscription for provider ({ProviderID}) with current seat minimums", provider.Id); "CB: Updated Stripe subscription for provider ({ProviderID}) with current seat minimums", provider.Id);
@ -325,6 +333,8 @@ public class ProviderMigrator(
var organizationCancellationCredit = organizationCustomers.Sum(customer => customer.Balance); var organizationCancellationCredit = organizationCustomers.Sum(customer => customer.Balance);
if (organizationCancellationCredit != 0)
{
await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId, await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId,
new CustomerBalanceTransactionCreateOptions new CustomerBalanceTransactionCreateOptions
{ {
@ -332,6 +342,7 @@ public class ProviderMigrator(
Currency = "USD", Currency = "USD",
Description = "Unused, prorated time for client organization subscriptions." Description = "Unused, prorated time for client organization subscriptions."
}); });
}
var migrationRecords = await Task.WhenAll(organizations.Select(organization => var migrationRecords = await Task.WhenAll(organizations.Select(organization =>
clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id))); clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id)));

View File

@ -2,9 +2,6 @@
public record OrganizationMetadata( public record OrganizationMetadata(
bool IsEligibleForSelfHost, bool IsEligibleForSelfHost,
bool IsOnSecretsManagerStandalone) bool IsManaged,
{ bool IsOnSecretsManagerStandalone,
public static OrganizationMetadata Default() => new( bool IsSubscriptionUnpaid);
IsEligibleForSelfHost: false,
IsOnSecretsManagerStandalone: false);
}

View File

@ -87,7 +87,9 @@ public record EnterprisePlan : Plan
AdditionalStoragePricePerGb = 4; AdditionalStoragePricePerGb = 4;
StripeStoragePlanId = "storage-gb-annually"; StripeStoragePlanId = "storage-gb-annually";
StripeSeatPlanId = "2023-enterprise-org-seat-annually"; StripeSeatPlanId = "2023-enterprise-org-seat-annually";
StripeProviderPortalSeatPlanId = "password-manager-provider-portal-enterprise-annually-2024";
SeatPrice = 72; SeatPrice = 72;
ProviderPortalSeatPrice = 72;
} }
else else
{ {

View File

@ -0,0 +1,8 @@
using Bit.Core.Billing.Enums;
namespace Bit.Core.Billing.Services.Contracts;
public record ChangeProviderPlanCommand(
Guid ProviderPlanId,
PlanType NewPlan,
string GatewaySubscriptionId);

View File

@ -0,0 +1,10 @@
using Bit.Core.Billing.Enums;
namespace Bit.Core.Billing.Services.Contracts;
/// <param name="Id">The ID of the provider to update the seat minimums for.</param>
/// <param name="Configuration">The new seat minimums for the provider.</param>
public record UpdateProviderSeatMinimumsCommand(
Guid Id,
string GatewaySubscriptionId,
IReadOnlyCollection<(PlanType Plan, int SeatsMinimum)> Configuration);

View File

@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Entities; using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Stripe; using Stripe;
@ -89,8 +90,12 @@ public interface IProviderBillingService
Task<Subscription> SetupSubscription( Task<Subscription> SetupSubscription(
Provider provider); Provider provider);
Task UpdateSeatMinimums( /// <summary>
Provider provider, /// Changes the assigned provider plan for the provider.
int enterpriseSeatMinimum, /// </summary>
int teamsSeatMinimum); /// <param name="command">The command to change the provider plan.</param>
/// <returns></returns>
Task ChangePlan(ChangeProviderPlanCommand command);
Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command);
} }

View File

@ -1,5 +1,4 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
@ -27,7 +26,6 @@ public class OrganizationBillingService(
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
ILogger<OrganizationBillingService> logger, ILogger<OrganizationBillingService> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache, ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IOrganizationBillingService ISubscriberService subscriberService) : IOrganizationBillingService
@ -64,18 +62,18 @@ public class OrganizationBillingService(
return null; return null;
} }
var customer = await subscriberService.GetCustomer(organization, new CustomerGetOptions var customer = await subscriberService.GetCustomer(organization,
{ new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] });
Expand = ["discount.coupon.applies_to"]
});
var subscription = await subscriberService.GetSubscription(organization); var subscription = await subscriberService.GetSubscription(organization);
var isEligibleForSelfHost = await IsEligibleForSelfHost(organization, subscription); var isEligibleForSelfHost = IsEligibleForSelfHost(organization);
var isManaged = organization.Status == OrganizationStatusType.Managed;
var isOnSecretsManagerStandalone = IsOnSecretsManagerStandalone(organization, customer, subscription); var isOnSecretsManagerStandalone = IsOnSecretsManagerStandalone(organization, customer, subscription);
var isSubscriptionUnpaid = IsSubscriptionUnpaid(subscription);
return new OrganizationMetadata(isEligibleForSelfHost, isOnSecretsManagerStandalone); return new OrganizationMetadata(isEligibleForSelfHost, isManaged, isOnSecretsManagerStandalone,
isSubscriptionUnpaid);
} }
public async Task UpdatePaymentMethod( public async Task UpdatePaymentMethod(
@ -339,26 +337,12 @@ public class OrganizationBillingService(
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
} }
private async Task<bool> IsEligibleForSelfHost( private static bool IsEligibleForSelfHost(
Organization organization, Organization organization)
Subscription? organizationSubscription)
{ {
if (organization.Status != OrganizationStatusType.Managed) var eligibleSelfHostPlans = StaticStore.Plans.Where(plan => plan.HasSelfHost).Select(plan => plan.Type);
{
return organization.Plan.Contains("Families") ||
organization.Plan.Contains("Enterprise") && IsActive(organizationSubscription);
}
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id); return eligibleSelfHostPlans.Contains(organization.PlanType);
var providerSubscription = await subscriberService.GetSubscriptionOrThrow(provider);
return organization.Plan.Contains("Enterprise") && IsActive(providerSubscription);
bool IsActive(Subscription? subscription) => subscription?.Status is
StripeConstants.SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.Trialing or
StripeConstants.SubscriptionStatus.PastDue;
} }
private static bool IsOnSecretsManagerStandalone( private static bool IsOnSecretsManagerStandalone(
@ -392,5 +376,16 @@ public class OrganizationBillingService(
return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any();
} }
private static bool IsSubscriptionUnpaid(Subscription subscription)
{
if (subscription == null)
{
return false;
}
return subscription.Status == "unpaid";
}
#endregion #endregion
} }

View File

@ -106,7 +106,6 @@ public static class FeatureFlagKeys
public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection"; public const string UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection";
public const string ItemShare = "item-share"; public const string ItemShare = "item-share";
public const string DuoRedirect = "duo-redirect"; public const string DuoRedirect = "duo-redirect";
public const string PM5864DollarThreshold = "PM-5864-dollar-threshold";
public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email"; public const string AC2101UpdateTrialInitiationEmail = "AC-2101-update-trial-initiation-email";
public const string EnableConsolidatedBilling = "enable-consolidated-billing"; public const string EnableConsolidatedBilling = "enable-consolidated-billing";
public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section"; public const string AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section";
@ -117,7 +116,6 @@ public static class FeatureFlagKeys
public const string RestrictProviderAccess = "restrict-provider-access"; public const string RestrictProviderAccess = "restrict-provider-access";
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service"; public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
public const string VaultBulkManagementAction = "vault-bulk-management-action"; public const string VaultBulkManagementAction = "vault-bulk-management-action";
public const string BulkDeviceApproval = "bulk-device-approval";
public const string MemberAccessReport = "ac-2059-member-access-report"; public const string MemberAccessReport = "ac-2059-member-access-report";
public const string BlockLegacyUsers = "block-legacy-users"; public const string BlockLegacyUsers = "block-legacy-users";
public const string InlineMenuFieldQualification = "inline-menu-field-qualification"; public const string InlineMenuFieldQualification = "inline-menu-field-qualification";
@ -142,12 +140,14 @@ public static class FeatureFlagKeys
public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill"; public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill";
public const string StorageReseedRefactor = "storage-reseed-refactor"; public const string StorageReseedRefactor = "storage-reseed-refactor";
public const string TrialPayment = "PM-8163-trial-payment"; public const string TrialPayment = "PM-8163-trial-payment";
public const string Pm3478RefactorOrganizationUserApi = "pm-3478-refactor-organizationuser-api";
public const string RemoveServerVersionHeader = "remove-server-version-header"; public const string RemoveServerVersionHeader = "remove-server-version-header";
public const string AccessIntelligence = "pm-13227-access-intelligence"; public const string AccessIntelligence = "pm-13227-access-intelligence";
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
public const string PM12275_MultiOrganizationEnterprises = "pm-12275-multi-organization-enterprises";
public const string Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions"; public const string Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions";
public const string LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split"; public const string LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split";
public const string GeneratorToolsModernization = "generator-tools-modernization";
public const string NewDeviceVerification = "new-device-verification";
public static List<string> GetAllKeys() public static List<string> GetAllKeys()
{ {
@ -163,7 +163,6 @@ public static class FeatureFlagKeys
return new Dictionary<string, string>() return new Dictionary<string, string>()
{ {
{ DuoRedirect, "true" }, { DuoRedirect, "true" },
{ BulkDeviceApproval, "true" },
{ CipherKeyEncryption, "true" }, { CipherKeyEncryption, "true" },
}; };
} }

View File

@ -21,11 +21,11 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" /> <PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.24" /> <PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.30" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.34" /> <PackageReference Include="AWSSDK.SQS" Version="3.7.400.40" />
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" /> <PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" /> <PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.18.1" /> <PackageReference Include="Azure.Messaging.ServiceBus" Version="7.18.1" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.21.2" /> <PackageReference Include="Azure.Storage.Blobs" Version="12.21.2" />
<PackageReference Include="Azure.Storage.Queues" Version="12.19.1" /> <PackageReference Include="Azure.Storage.Queues" Version="12.19.1" />
@ -35,22 +35,22 @@
<PackageReference Include="Fido2.AspNet" Version="3.0.1" /> <PackageReference Include="Fido2.AspNet" Version="3.0.1" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" /> <PackageReference Include="Handlebars.Net" Version="2.1.6" />
<PackageReference Include="MailKit" Version="4.8.0" /> <PackageReference Include="MailKit" Version="4.8.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.44.0" /> <PackageReference Include="Microsoft.Azure.Cosmos" Version="3.45.0" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" /> <PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.1" /> <PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.6.1" />
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.8" /> <PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="8.0.8" /> <PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="8.0.10" />
<PackageReference Include="Quartz" Version="3.9.0" /> <PackageReference Include="Quartz" Version="3.9.0" />
<PackageReference Include="SendGrid" Version="9.29.3" /> <PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" /> <PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" /> <PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" /> <PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
<PackageReference Include="Sentry.Serilog" Version="3.41.4" /> <PackageReference Include="Sentry.Serilog" Version="3.41.4" />
<PackageReference Include="Duende.IdentityServer" Version="7.0.6" /> <PackageReference Include="Duende.IdentityServer" Version="7.0.8" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="4.0.0" /> <PackageReference Include="Serilog.Sinks.SyslogMessages" Version="4.0.0" />
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" /> <PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
@ -58,8 +58,8 @@
<PackageReference Include="Stripe.net" Version="45.14.0" /> <PackageReference Include="Stripe.net" Version="45.14.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" /> <PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" /> <PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.8" /> <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.5.2" /> <PackageReference Include="LaunchDarkly.ServerSdk" Version="8.6.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -38,6 +38,10 @@ public class Device : ITableObject<Guid>
/// </summary> /// </summary>
public string? EncryptedPrivateKey { get; set; } public string? EncryptedPrivateKey { get; set; }
/// <summary>
/// Whether the device is active for the user.
/// </summary>
public bool Active { get; set; } = true;
public void SetNewId() public void SetNewId()
{ {

View File

@ -0,0 +1,15 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
You have requested to delete your account. This action cannot be completed because your account is owned by an organization.
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none; text-align: left;" valign="top" align="center">
Please contact your organization administrator for additional details.
<br style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,6 @@
{{#>BasicTextLayout}}
You have requested to delete your account. This action cannot be completed because your account is owned by an organization.
Please contact your organization administrator for additional details.
{{/BasicTextLayout}}

View File

@ -130,6 +130,7 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IGetOrganizationDomainByIdOrganizationIdQuery, GetOrganizationDomainByIdOrganizationIdQuery>(); services.AddScoped<IGetOrganizationDomainByIdOrganizationIdQuery, GetOrganizationDomainByIdOrganizationIdQuery>();
services.AddScoped<IGetOrganizationDomainByOrganizationIdQuery, GetOrganizationDomainByOrganizationIdQuery>(); services.AddScoped<IGetOrganizationDomainByOrganizationIdQuery, GetOrganizationDomainByOrganizationIdQuery>();
services.AddScoped<IDeleteOrganizationDomainCommand, DeleteOrganizationDomainCommand>(); services.AddScoped<IDeleteOrganizationDomainCommand, DeleteOrganizationDomainCommand>();
services.AddScoped<IOrganizationHasVerifiedDomainsQuery, OrganizationHasVerifiedDomainsQuery>();
} }
private static void AddOrganizationAuthCommands(this IServiceCollection services) private static void AddOrganizationAuthCommands(this IServiceCollection services)

View File

@ -7,7 +7,7 @@ public interface IDeviceService
{ {
Task SaveAsync(Device device); Task SaveAsync(Device device);
Task ClearTokenAsync(Device device); Task ClearTokenAsync(Device device);
Task DeleteAsync(Device device); Task DeactivateAsync(Device device);
Task UpdateDevicesTrustAsync(string currentDeviceIdentifier, Task UpdateDevicesTrustAsync(string currentDeviceIdentifier,
Guid currentUserId, Guid currentUserId,
DeviceKeysUpdateRequestModel currentDeviceUpdate, DeviceKeysUpdateRequestModel currentDeviceUpdate,

View File

@ -18,6 +18,7 @@ public interface IMailService
ProductTierType productTier, ProductTierType productTier,
IEnumerable<ProductType> products); IEnumerable<ProductType> products);
Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token); Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token);
Task SendCannotDeleteManagedAccountEmailAsync(string email);
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
Task SendChangeEmailEmailAsync(string newEmailAddress, string token); Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
Task SendTwoFactorEmailAsync(string email, string token); Task SendTwoFactorEmailAsync(string email, string token);

View File

@ -14,6 +14,17 @@ public interface IStripeAdapter
CustomerBalanceTransactionCreateOptions options); CustomerBalanceTransactionCreateOptions options);
Task<Stripe.Subscription> SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions subscriptionCreateOptions); Task<Stripe.Subscription> SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions subscriptionCreateOptions);
Task<Stripe.Subscription> SubscriptionGetAsync(string id, Stripe.SubscriptionGetOptions options = null); Task<Stripe.Subscription> SubscriptionGetAsync(string id, Stripe.SubscriptionGetOptions options = null);
/// <summary>
/// Retrieves a subscription object for a provider.
/// </summary>
/// <param name="id">The subscription ID.</param>
/// <param name="providerId">The provider ID.</param>
/// <param name="options">Additional options.</param>
/// <returns>The subscription object.</returns>
/// <exception cref="InvalidOperationException">Thrown when the subscription doesn't belong to the provider.</exception>
Task<Stripe.Subscription> ProviderSubscriptionGetAsync(string id, Guid providerId, Stripe.SubscriptionGetOptions options = null);
Task<List<Stripe.Subscription>> SubscriptionListAsync(StripeSubscriptionListOptions subscriptionSearchOptions); Task<List<Stripe.Subscription>> SubscriptionListAsync(StripeSubscriptionListOptions subscriptionSearchOptions);
Task<Stripe.Subscription> SubscriptionUpdateAsync(string id, Stripe.SubscriptionUpdateOptions options = null); Task<Stripe.Subscription> SubscriptionUpdateAsync(string id, Stripe.SubscriptionUpdateOptions options = null);
Task<Stripe.Subscription> SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options = null); Task<Stripe.Subscription> SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options = null);

View File

@ -41,9 +41,18 @@ public class DeviceService : IDeviceService
await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString()); await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString());
} }
public async Task DeleteAsync(Device device) public async Task DeactivateAsync(Device device)
{ {
await _deviceRepository.DeleteAsync(device); // already deactivated
if (!device.Active)
{
return;
}
device.Active = false;
device.RevisionDate = DateTime.UtcNow;
await _deviceRepository.UpsertAsync(device);
await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString()); await _pushRegistrationService.DeleteRegistrationAsync(device.Id.ToString());
} }

View File

@ -112,6 +112,19 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message); await _mailDeliveryService.SendEmailAsync(message);
} }
public async Task SendCannotDeleteManagedAccountEmailAsync(string email)
{
var message = CreateDefaultMessage("Delete Your Account", email);
var model = new CannotDeleteManagedAccountViewModel
{
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName,
};
await AddMessageContentAsync(message, "AdminConsole.CannotDeleteManagedAccount", model);
message.Category = "CannotDeleteManagedAccount";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail) public async Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail)
{ {
var message = CreateDefaultMessage("Your Email Change", toEmail); var message = CreateDefaultMessage("Your Email Change", toEmail);

View File

@ -79,6 +79,20 @@ public class StripeAdapter : IStripeAdapter
return _subscriptionService.GetAsync(id, options); return _subscriptionService.GetAsync(id, options);
} }
public async Task<Subscription> ProviderSubscriptionGetAsync(
string id,
Guid providerId,
SubscriptionGetOptions options = null)
{
var subscription = await _subscriptionService.GetAsync(id, options);
if (subscription.Metadata.TryGetValue("providerId", out var value) && value == providerId.ToString())
{
return subscription;
}
throw new InvalidOperationException("Subscription does not belong to the provider.");
}
public Task<Stripe.Subscription> SubscriptionUpdateAsync(string id, public Task<Stripe.Subscription> SubscriptionUpdateAsync(string id,
Stripe.SubscriptionUpdateOptions options = null) Stripe.SubscriptionUpdateOptions options = null)
{ {

View File

@ -792,19 +792,16 @@ public class StripePaymentService : IPaymentService
var daysUntilDue = sub.DaysUntilDue; var daysUntilDue = sub.DaysUntilDue;
var chargeNow = collectionMethod == "charge_automatically"; var chargeNow = collectionMethod == "charge_automatically";
var updatedItemOptions = subscriptionUpdate.UpgradeItemsOptions(sub); var updatedItemOptions = subscriptionUpdate.UpgradeItemsOptions(sub);
var isPm5864DollarThresholdEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5864DollarThreshold);
var isAnnualPlan = sub?.Items?.Data.FirstOrDefault()?.Plan?.Interval == "year"; var isAnnualPlan = sub?.Items?.Data.FirstOrDefault()?.Plan?.Interval == "year";
var subUpdateOptions = new SubscriptionUpdateOptions var subUpdateOptions = new SubscriptionUpdateOptions
{ {
Items = updatedItemOptions, Items = updatedItemOptions,
ProrationBehavior = !isPm5864DollarThresholdEnabled || invoiceNow ProrationBehavior = invoiceNow ? Constants.AlwaysInvoice : Constants.CreateProrations,
? Constants.AlwaysInvoice
: Constants.CreateProrations,
DaysUntilDue = daysUntilDue ?? 1, DaysUntilDue = daysUntilDue ?? 1,
CollectionMethod = "send_invoice" CollectionMethod = "send_invoice"
}; };
if (!invoiceNow && isAnnualPlan && isPm5864DollarThresholdEnabled && sub.Status.Trim() != "trialing") if (!invoiceNow && isAnnualPlan && sub.Status.Trim() != "trialing")
{ {
subUpdateOptions.PendingInvoiceItemInterval = subUpdateOptions.PendingInvoiceItemInterval =
new SubscriptionPendingInvoiceItemIntervalOptions { Interval = "month" }; new SubscriptionPendingInvoiceItemIntervalOptions { Interval = "month" };
@ -838,7 +835,7 @@ public class StripePaymentService : IPaymentService
{ {
try try
{ {
if (!isPm5864DollarThresholdEnabled && !invoiceNow) if (invoiceNow)
{ {
if (chargeNow) if (chargeNow)
{ {

View File

@ -297,6 +297,12 @@ public class UserService : UserManager<User>, IUserService, IDisposable
return; return;
} }
if (await IsManagedByAnyOrganizationAsync(user.Id))
{
await _mailService.SendCannotDeleteManagedAccountEmailAsync(user.Email);
return;
}
var token = await base.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "DeleteAccount"); var token = await base.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "DeleteAccount");
await _mailService.SendVerifyDeleteEmailAsync(user.Email, user.Id, token); await _mailService.SendVerifyDeleteEmailAsync(user.Email, user.Id, token);
} }

View File

@ -94,6 +94,11 @@ public class NoopMailService : IMailService
return Task.FromResult(0); return Task.FromResult(0);
} }
public Task SendCannotDeleteManagedAccountEmailAsync(string email)
{
return Task.FromResult(0);
}
public Task SendPasswordlessSignInAsync(string returnUrl, string token, string email) public Task SendPasswordlessSignInAsync(string returnUrl, string token, string email)
{ {
return Task.FromResult(0); return Task.FromResult(0);

View File

@ -1,4 +1,5 @@
using Bit.Core; using System.Diagnostics;
using Bit.Core;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Api.Response.Accounts; using Bit.Core.Auth.Models.Api.Response.Accounts;
@ -149,40 +150,44 @@ public class AccountsController : Controller
IdentityResult identityResult = null; IdentityResult identityResult = null;
var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays); var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays);
if (!string.IsNullOrEmpty(model.OrgInviteToken) && model.OrganizationUserId.HasValue) switch (model.GetTokenType())
{ {
identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash, case RegisterFinishTokenType.EmailVerification:
model.OrgInviteToken, model.OrganizationUserId);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
}
if (!string.IsNullOrEmpty(model.OrgSponsoredFreeFamilyPlanToken))
{
identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
}
if (!string.IsNullOrEmpty(model.AcceptEmergencyAccessInviteToken) && model.AcceptEmergencyAccessId.HasValue)
{
identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash,
model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
}
if (string.IsNullOrEmpty(model.EmailVerificationToken))
{
throw new BadRequestException("Invalid registration finish request");
}
identityResult = identityResult =
await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash, await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash,
model.EmailVerificationToken); model.EmailVerificationToken);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled); return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
break;
case RegisterFinishTokenType.OrganizationInvite:
identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash,
model.OrgInviteToken, model.OrganizationUserId);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
break;
case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan:
identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, model.MasterPasswordHash, model.OrgSponsoredFreeFamilyPlanToken);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
break;
case RegisterFinishTokenType.EmergencyAccessInvite:
Debug.Assert(model.AcceptEmergencyAccessId.HasValue);
identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken(user, model.MasterPasswordHash,
model.AcceptEmergencyAccessInviteToken, model.AcceptEmergencyAccessId.Value);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
break;
case RegisterFinishTokenType.ProviderInvite:
Debug.Assert(model.ProviderUserId.HasValue);
identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken(user, model.MasterPasswordHash,
model.ProviderInviteToken, model.ProviderUserId.Value);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
break;
default:
throw new BadRequestException("Invalid registration finish request");
}
} }
private async Task<RegisterResponseModel> ProcessRegistrationResult(IdentityResult result, User user, bool delaysEnabled) private async Task<RegisterResponseModel> ProcessRegistrationResult(IdentityResult result, User user, bool delaysEnabled)

View File

@ -1,4 +1,5 @@
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Identity.IdentityServer.RequestValidators;
using Duende.IdentityServer.Models; using Duende.IdentityServer.Models;
namespace Bit.Identity.IdentityServer; namespace Bit.Identity.IdentityServer;

View File

@ -1,15 +1,10 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
@ -17,32 +12,26 @@ using Bit.Core.Enums;
using Bit.Core.Identity; using Bit.Core.Identity;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Models.Api.Response; using Bit.Core.Models.Api.Response;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Duende.IdentityServer.Validation; using Duende.IdentityServer.Validation;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace Bit.Identity.IdentityServer; namespace Bit.Identity.IdentityServer.RequestValidators;
public abstract class BaseRequestValidator<T> where T : class public abstract class BaseRequestValidator<T> where T : class
{ {
private UserManager<User> _userManager; private UserManager<User> _userManager;
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IDeviceValidator _deviceValidator; private readonly IDeviceValidator _deviceValidator;
private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider; private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator;
private readonly ITemporaryDuoWebV4SDKService _duoWebV4SDKService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IMailService _mailService; private readonly IMailService _mailService;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _tokenDataFactory;
protected ICurrentContext CurrentContext { get; } protected ICurrentContext CurrentContext { get; }
protected IPolicyService PolicyService { get; } protected IPolicyService PolicyService { get; }
@ -56,18 +45,14 @@ public abstract class BaseRequestValidator<T> where T : class
IUserService userService, IUserService userService,
IEventService eventService, IEventService eventService,
IDeviceValidator deviceValidator, IDeviceValidator deviceValidator,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
ITemporaryDuoWebV4SDKService duoWebV4SDKService,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IApplicationCacheService applicationCacheService,
IMailService mailService, IMailService mailService,
ILogger logger, ILogger logger,
ICurrentContext currentContext, ICurrentContext currentContext,
GlobalSettings globalSettings, GlobalSettings globalSettings,
IUserRepository userRepository, IUserRepository userRepository,
IPolicyService policyService, IPolicyService policyService,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
IFeatureService featureService, IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
@ -76,18 +61,14 @@ public abstract class BaseRequestValidator<T> where T : class
_userService = userService; _userService = userService;
_eventService = eventService; _eventService = eventService;
_deviceValidator = deviceValidator; _deviceValidator = deviceValidator;
_organizationDuoWebTokenProvider = organizationDuoWebTokenProvider; _twoFactorAuthenticationValidator = twoFactorAuthenticationValidator;
_duoWebV4SDKService = duoWebV4SDKService;
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_applicationCacheService = applicationCacheService;
_mailService = mailService; _mailService = mailService;
_logger = logger; _logger = logger;
CurrentContext = currentContext; CurrentContext = currentContext;
_globalSettings = globalSettings; _globalSettings = globalSettings;
PolicyService = policyService; PolicyService = policyService;
_userRepository = userRepository; _userRepository = userRepository;
_tokenDataFactory = tokenDataFactory;
FeatureService = featureService; FeatureService = featureService;
SsoConfigRepository = ssoConfigRepository; SsoConfigRepository = ssoConfigRepository;
UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder; UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder;
@ -104,12 +85,6 @@ public abstract class BaseRequestValidator<T> where T : class
request.UserName, validatorContext.CaptchaResponse.Score); request.UserName, validatorContext.CaptchaResponse.Score);
} }
var twoFactorToken = request.Raw["TwoFactorToken"]?.ToString();
var twoFactorProvider = request.Raw["TwoFactorProvider"]?.ToString();
var twoFactorRemember = request.Raw["TwoFactorRemember"]?.ToString() == "1";
var twoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
!string.IsNullOrWhiteSpace(twoFactorProvider);
var valid = await ValidateContextAsync(context, validatorContext); var valid = await ValidateContextAsync(context, validatorContext);
var user = validatorContext.User; var user = validatorContext.User;
if (!valid) if (!valid)
@ -123,17 +98,37 @@ public abstract class BaseRequestValidator<T> where T : class
return; return;
} }
var (isTwoFactorRequired, twoFactorOrganization) = await RequiresTwoFactorAsync(user, request); var (isTwoFactorRequired, twoFactorOrganization) = await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request);
var twoFactorToken = request.Raw["TwoFactorToken"]?.ToString();
var twoFactorProvider = request.Raw["TwoFactorProvider"]?.ToString();
var twoFactorRemember = request.Raw["TwoFactorRemember"]?.ToString() == "1";
var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
!string.IsNullOrWhiteSpace(twoFactorProvider);
if (isTwoFactorRequired) if (isTwoFactorRequired)
{ {
if (!twoFactorRequest || !Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType)) // 2FA required and not provided response
if (!validTwoFactorRequest ||
!Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType))
{ {
await BuildTwoFactorResultAsync(user, twoFactorOrganization, context); var resultDict = await _twoFactorAuthenticationValidator
.BuildTwoFactorResultAsync(user, twoFactorOrganization);
if (resultDict == null)
{
await BuildErrorResultAsync("No two-step providers enabled.", false, context, user);
return; return;
} }
var verified = await VerifyTwoFactor(user, twoFactorOrganization, // Include Master Password Policy in 2FA response
twoFactorProviderType, twoFactorToken); resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicy(user));
SetTwoFactorResult(context, resultDict);
return;
}
var verified = await _twoFactorAuthenticationValidator
.VerifyTwoFactor(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken);
// 2FA required but request not valid or remember token expired response
if (!verified || isBot) if (!verified || isBot)
{ {
if (twoFactorProviderType != TwoFactorProviderType.Remember) if (twoFactorProviderType != TwoFactorProviderType.Remember)
@ -143,16 +138,20 @@ public abstract class BaseRequestValidator<T> where T : class
} }
else if (twoFactorProviderType == TwoFactorProviderType.Remember) else if (twoFactorProviderType == TwoFactorProviderType.Remember)
{ {
await BuildTwoFactorResultAsync(user, twoFactorOrganization, context); var resultDict = await _twoFactorAuthenticationValidator
.BuildTwoFactorResultAsync(user, twoFactorOrganization);
// Include Master Password Policy in 2FA response
resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicy(user));
SetTwoFactorResult(context, resultDict);
} }
return; return;
} }
} }
else else
{ {
twoFactorRequest = false; validTwoFactorRequest = false;
twoFactorRemember = false; twoFactorRemember = false;
twoFactorToken = null;
} }
// Force legacy users to the web for migration // Force legacy users to the web for migration
@ -165,7 +164,6 @@ public abstract class BaseRequestValidator<T> where T : class
} }
} }
// Returns true if can finish validation process
if (await IsValidAuthTypeAsync(user, request.GrantType)) if (await IsValidAuthTypeAsync(user, request.GrantType))
{ {
var device = await _deviceValidator.SaveDeviceAsync(user, request); var device = await _deviceValidator.SaveDeviceAsync(user, request);
@ -174,8 +172,7 @@ public abstract class BaseRequestValidator<T> where T : class
await BuildErrorResultAsync("No device information provided.", false, context, user); await BuildErrorResultAsync("No device information provided.", false, context, user);
return; return;
} }
await BuildSuccessResultAsync(user, context, device, validTwoFactorRequest && twoFactorRemember);
await BuildSuccessResultAsync(user, context, device, twoFactorRequest && twoFactorRemember);
} }
else else
{ {
@ -238,67 +235,6 @@ public abstract class BaseRequestValidator<T> where T : class
await SetSuccessResult(context, user, claims, customResponse); await SetSuccessResult(context, user, claims, customResponse);
} }
protected async Task BuildTwoFactorResultAsync(User user, Organization organization, T context)
{
var providerKeys = new List<byte>();
var providers = new Dictionary<string, Dictionary<string, object>>();
var enabledProviders = new List<KeyValuePair<TwoFactorProviderType, TwoFactorProvider>>();
if (organization?.GetTwoFactorProviders() != null)
{
enabledProviders.AddRange(organization.GetTwoFactorProviders().Where(
p => organization.TwoFactorProviderIsEnabled(p.Key)));
}
if (user.GetTwoFactorProviders() != null)
{
foreach (var p in user.GetTwoFactorProviders())
{
if (await _userService.TwoFactorProviderIsEnabledAsync(p.Key, user))
{
enabledProviders.Add(p);
}
}
}
if (!enabledProviders.Any())
{
await BuildErrorResultAsync("No two-step providers enabled.", false, context, user);
return;
}
foreach (var provider in enabledProviders)
{
providerKeys.Add((byte)provider.Key);
var infoDict = await BuildTwoFactorParams(organization, user, provider.Key, provider.Value);
providers.Add(((byte)provider.Key).ToString(), infoDict);
}
var twoFactorResultDict = new Dictionary<string, object>
{
{ "TwoFactorProviders", providers.Keys },
{ "TwoFactorProviders2", providers },
{ "MasterPasswordPolicy", await GetMasterPasswordPolicy(user) },
};
// If we have email as a 2FA provider, we might need an SsoEmail2fa Session Token
if (enabledProviders.Any(p => p.Key == TwoFactorProviderType.Email))
{
twoFactorResultDict.Add("SsoEmail2faSessionToken",
_tokenDataFactory.Protect(new SsoEmail2faSessionTokenable(user)));
twoFactorResultDict.Add("Email", user.Email);
}
SetTwoFactorResult(context, twoFactorResultDict);
if (enabledProviders.Count() == 1 && enabledProviders.First().Key == TwoFactorProviderType.Email)
{
// Send email now if this is their only 2FA method
await _userService.SendTwoFactorEmailAsync(user);
}
}
protected async Task BuildErrorResultAsync(string message, bool twoFactorRequest, T context, User user) protected async Task BuildErrorResultAsync(string message, bool twoFactorRequest, T context, User user)
{ {
if (user != null) if (user != null)
@ -329,35 +265,13 @@ public abstract class BaseRequestValidator<T> where T : class
protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse); protected abstract void SetErrorResult(T context, Dictionary<string, object> customResponse);
protected abstract ClaimsPrincipal GetSubject(T context); protected abstract ClaimsPrincipal GetSubject(T context);
protected virtual async Task<Tuple<bool, Organization>> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request) /// <summary>
{ /// Check if the user is required to authenticate via SSO. If the user requires SSO, but they are
if (request.GrantType == "client_credentials") /// logging in using an API Key (client_credentials) then they are allowed to bypass the SSO requirement.
{ /// </summary>
// Do not require MFA for api key logins /// <param name="user">user trying to login</param>
return new Tuple<bool, Organization>(false, null); /// <param name="grantType">magic string identifying the grant type requested</param>
} /// <returns></returns>
var individualRequired = _userManager.SupportsUserTwoFactor &&
await _userManager.GetTwoFactorEnabledAsync(user) &&
(await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0;
Organization firstEnabledOrg = null;
var orgs = (await CurrentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)).ToList();
if (orgs.Count > 0)
{
var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var twoFactorOrgs = orgs.Where(o => OrgUsing2fa(orgAbilities, o.Id));
if (twoFactorOrgs.Any())
{
var userOrgs = await _organizationRepository.GetManyByUserIdAsync(user.Id);
firstEnabledOrg = userOrgs.FirstOrDefault(
o => orgs.Any(om => om.Id == o.Id) && o.TwoFactorIsEnabled());
}
}
return new Tuple<bool, Organization>(individualRequired || firstEnabledOrg != null, firstEnabledOrg);
}
private async Task<bool> IsValidAuthTypeAsync(User user, string grantType) private async Task<bool> IsValidAuthTypeAsync(User user, string grantType)
{ {
if (grantType == "authorization_code" || grantType == "client_credentials") if (grantType == "authorization_code" || grantType == "client_credentials")
@ -367,7 +281,6 @@ public abstract class BaseRequestValidator<T> where T : class
return true; return true;
} }
// Check if user belongs to any organization with an active SSO policy // Check if user belongs to any organization with an active SSO policy
var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
if (anySsoPoliciesApplicableToUser) if (anySsoPoliciesApplicableToUser)
@ -379,134 +292,6 @@ public abstract class BaseRequestValidator<T> where T : class
return true; return true;
} }
private bool OrgUsing2fa(IDictionary<Guid, OrganizationAbility> orgAbilities, Guid orgId)
{
return orgAbilities != null && orgAbilities.ContainsKey(orgId) &&
orgAbilities[orgId].Enabled && orgAbilities[orgId].Using2fa;
}
private async Task<bool> VerifyTwoFactor(User user, Organization organization, TwoFactorProviderType type,
string token)
{
switch (type)
{
case TwoFactorProviderType.Authenticator:
case TwoFactorProviderType.Email:
case TwoFactorProviderType.Duo:
case TwoFactorProviderType.YubiKey:
case TwoFactorProviderType.WebAuthn:
case TwoFactorProviderType.Remember:
if (type != TwoFactorProviderType.Remember &&
!await _userService.TwoFactorProviderIsEnabledAsync(type, user))
{
return false;
}
// DUO SDK v4 Update: try to validate the token - PM-5156 addresses tech debt
if (FeatureService.IsEnabled(FeatureFlagKeys.DuoRedirect))
{
if (type == TwoFactorProviderType.Duo)
{
if (!token.Contains(':'))
{
// We have to send the provider to the DuoWebV4SDKService to create the DuoClient
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
return await _duoWebV4SDKService.ValidateAsync(token, provider, user);
}
}
}
return await _userManager.VerifyTwoFactorTokenAsync(user,
CoreHelpers.CustomProviderName(type), token);
case TwoFactorProviderType.OrganizationDuo:
if (!organization?.TwoFactorProviderIsEnabled(type) ?? true)
{
return false;
}
// DUO SDK v4 Update: try to validate the token - PM-5156 addresses tech debt
if (FeatureService.IsEnabled(FeatureFlagKeys.DuoRedirect))
{
if (type == TwoFactorProviderType.OrganizationDuo)
{
if (!token.Contains(':'))
{
// We have to send the provider to the DuoWebV4SDKService to create the DuoClient
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
return await _duoWebV4SDKService.ValidateAsync(token, provider, user);
}
}
}
return await _organizationDuoWebTokenProvider.ValidateAsync(token, organization, user);
default:
return false;
}
}
private async Task<Dictionary<string, object>> BuildTwoFactorParams(Organization organization, User user,
TwoFactorProviderType type, TwoFactorProvider provider)
{
switch (type)
{
case TwoFactorProviderType.Duo:
case TwoFactorProviderType.WebAuthn:
case TwoFactorProviderType.Email:
case TwoFactorProviderType.YubiKey:
if (!await _userService.TwoFactorProviderIsEnabledAsync(type, user))
{
return null;
}
var token = await _userManager.GenerateTwoFactorTokenAsync(user,
CoreHelpers.CustomProviderName(type));
if (type == TwoFactorProviderType.Duo)
{
var duoResponse = new Dictionary<string, object>
{
["Host"] = provider.MetaData["Host"],
["AuthUrl"] = await _duoWebV4SDKService.GenerateAsync(provider, user),
};
return duoResponse;
}
else if (type == TwoFactorProviderType.WebAuthn)
{
if (token == null)
{
return null;
}
return JsonSerializer.Deserialize<Dictionary<string, object>>(token);
}
else if (type == TwoFactorProviderType.Email)
{
var twoFactorEmail = (string)provider.MetaData["Email"];
var redactedEmail = CoreHelpers.RedactEmailAddress(twoFactorEmail);
return new Dictionary<string, object> { ["Email"] = redactedEmail };
}
else if (type == TwoFactorProviderType.YubiKey)
{
return new Dictionary<string, object> { ["Nfc"] = (bool)provider.MetaData["Nfc"] };
}
return null;
case TwoFactorProviderType.OrganizationDuo:
if (await _organizationDuoWebTokenProvider.CanGenerateTwoFactorTokenAsync(organization))
{
var duoResponse = new Dictionary<string, object>
{
["Host"] = provider.MetaData["Host"],
["AuthUrl"] = await _duoWebV4SDKService.GenerateAsync(provider, user),
};
return duoResponse;
}
return null;
default:
return null;
}
}
private async Task ResetFailedAuthDetailsAsync(User user) private async Task ResetFailedAuthDetailsAsync(User user)
{ {
// Early escape if db hit not necessary // Early escape if db hit not necessary

View File

@ -1,9 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.Security.Claims; using System.Security.Claims;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
@ -11,7 +9,6 @@ using Bit.Core.IdentityServer;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tokens;
using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Validation; using Duende.IdentityServer.Validation;
using HandlebarsDotNet; using HandlebarsDotNet;
@ -20,7 +17,7 @@ using Microsoft.AspNetCore.Identity;
#nullable enable #nullable enable
namespace Bit.Identity.IdentityServer; namespace Bit.Identity.IdentityServer.RequestValidators;
public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenRequestValidationContext>, public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenRequestValidationContext>,
ICustomTokenRequestValidator ICustomTokenRequestValidator
@ -29,28 +26,36 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
public CustomTokenRequestValidator( public CustomTokenRequestValidator(
UserManager<User> userManager, UserManager<User> userManager,
IDeviceValidator deviceValidator,
IUserService userService, IUserService userService,
IEventService eventService, IEventService eventService,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, IDeviceValidator deviceValidator,
ITemporaryDuoWebV4SDKService duoWebV4SDKService, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IApplicationCacheService applicationCacheService,
IMailService mailService, IMailService mailService,
ILogger<CustomTokenRequestValidator> logger, ILogger<CustomTokenRequestValidator> logger,
ICurrentContext currentContext, ICurrentContext currentContext,
GlobalSettings globalSettings, GlobalSettings globalSettings,
ISsoConfigRepository ssoConfigRepository,
IUserRepository userRepository, IUserRepository userRepository,
IPolicyService policyService, IPolicyService policyService,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
IFeatureService featureService, IFeatureService featureService,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) ISsoConfigRepository ssoConfigRepository,
: base(userManager, userService, eventService, deviceValidator, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder
organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository, )
applicationCacheService, mailService, logger, currentContext, globalSettings, : base(
userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, userManager,
userService,
eventService,
deviceValidator,
twoFactorAuthenticationValidator,
organizationUserRepository,
mailService,
logger,
currentContext,
globalSettings,
userRepository,
policyService,
featureService,
ssoConfigRepository,
userDecryptionOptionsBuilder) userDecryptionOptionsBuilder)
{ {
_userManager = userManager; _userManager = userManager;
@ -70,7 +75,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
} }
} }
string[] allowedGrantTypes = { "authorization_code", "client_credentials" }; string[] allowedGrantTypes = ["authorization_code", "client_credentials"];
if (!allowedGrantTypes.Contains(context.Result.ValidatedRequest.GrantType) if (!allowedGrantTypes.Contains(context.Result.ValidatedRequest.GrantType)
|| context.Result.ValidatedRequest.ClientId.StartsWith("organization") || context.Result.ValidatedRequest.ClientId.StartsWith("organization")
|| context.Result.ValidatedRequest.ClientId.StartsWith("installation") || context.Result.ValidatedRequest.ClientId.StartsWith("installation")

View File

@ -8,7 +8,7 @@ using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Duende.IdentityServer.Validation; using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer; namespace Bit.Identity.IdentityServer.RequestValidators;
public interface IDeviceValidator public interface IDeviceValidator
{ {
@ -41,6 +41,12 @@ public class DeviceValidator(
private readonly IMailService _mailService = mailService; private readonly IMailService _mailService = mailService;
private readonly ICurrentContext _currentContext = currentContext; private readonly ICurrentContext _currentContext = currentContext;
/// <summary>
/// Save a device to the database. If the device is already known, it will be returned.
/// </summary>
/// <param name="user">The user is assumed NOT null, still going to check though</param>
/// <param name="request">Duende Validated Request that contains the data to create the device object</param>
/// <returns>Returns null if user or device is malformed; The existing device if already in DB; a new device login</returns>
public async Task<Device> SaveDeviceAsync(User user, ValidatedTokenRequest request) public async Task<Device> SaveDeviceAsync(User user, ValidatedTokenRequest request)
{ {
var device = GetDeviceFromRequest(request); var device = GetDeviceFromRequest(request);

View File

@ -1,8 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Services; using Bit.Core.Auth.Services;
using Bit.Core.Context; using Bit.Core.Context;
@ -10,13 +8,12 @@ using Bit.Core.Entities;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Duende.IdentityServer.Models; using Duende.IdentityServer.Models;
using Duende.IdentityServer.Validation; using Duende.IdentityServer.Validation;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace Bit.Identity.IdentityServer; namespace Bit.Identity.IdentityServer.RequestValidators;
public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwnerPasswordValidationContext>, public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwnerPasswordValidationContext>,
IResourceOwnerPasswordValidator IResourceOwnerPasswordValidator
@ -31,11 +28,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
IUserService userService, IUserService userService,
IEventService eventService, IEventService eventService,
IDeviceValidator deviceValidator, IDeviceValidator deviceValidator,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
ITemporaryDuoWebV4SDKService duoWebV4SDKService,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IApplicationCacheService applicationCacheService,
IMailService mailService, IMailService mailService,
ILogger<ResourceOwnerPasswordValidator> logger, ILogger<ResourceOwnerPasswordValidator> logger,
ICurrentContext currentContext, ICurrentContext currentContext,
@ -44,14 +38,25 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
IAuthRequestRepository authRequestRepository, IAuthRequestRepository authRequestRepository,
IUserRepository userRepository, IUserRepository userRepository,
IPolicyService policyService, IPolicyService policyService,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
IFeatureService featureService, IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
: base(userManager, userService, eventService, deviceValidator, : base(
organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository, userManager,
applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService, userService,
tokenDataFactory, featureService, ssoConfigRepository, userDecryptionOptionsBuilder) eventService,
deviceValidator,
twoFactorAuthenticationValidator,
organizationUserRepository,
mailService,
logger,
currentContext,
globalSettings,
userRepository,
policyService,
featureService,
ssoConfigRepository,
userDecryptionOptionsBuilder)
{ {
_userManager = userManager; _userManager = userManager;
_currentContext = currentContext; _currentContext = currentContext;

View File

@ -0,0 +1,297 @@

using System.Text.Json;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Duende.IdentityServer.Validation;
using Microsoft.AspNetCore.Identity;
namespace Bit.Identity.IdentityServer.RequestValidators;
public interface ITwoFactorAuthenticationValidator
{
/// <summary>
/// Check if the user is required to use two-factor authentication to login. This is based on the user's
/// enabled two-factor providers, the user's organizations enabled two-factor providers, and the grant type.
/// Client credentials and webauthn grant types do not require two-factor authentication.
/// </summary>
/// <param name="user">the active user for the request</param>
/// <param name="request">the request that contains the grant types</param>
/// <returns>boolean</returns>
Task<Tuple<bool, Organization>> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request);
/// <summary>
/// Builds the two-factor authentication result for the user based on the available two-factor providers
/// from either their user account or Organization.
/// </summary>
/// <param name="user">user trying to login</param>
/// <param name="organization">organization associated with the user; Can be null</param>
/// <returns>Dictionary with the TwoFactorProviderType as the Key and the Provider Metadata as the Value</returns>
Task<Dictionary<string, object>> BuildTwoFactorResultAsync(User user, Organization organization);
/// <summary>
/// Uses the built in userManager methods to verify the two-factor token for the user. If the organization uses
/// organization duo, it will use the organization duo token provider to verify the token.
/// </summary>
/// <param name="user">the active User</param>
/// <param name="organization">organization of user; can be null</param>
/// <param name="twoFactorProviderType">Two Factor Provider to use to verify the token</param>
/// <param name="token">secret passed from the user and consumed by the two-factor provider's verify method</param>
/// <returns>boolean</returns>
Task<bool> VerifyTwoFactor(User user, Organization organization, TwoFactorProviderType twoFactorProviderType, string token);
}
public class TwoFactorAuthenticationValidator(
IUserService userService,
UserManager<User> userManager,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
ITemporaryDuoWebV4SDKService duoWebV4SDKService,
IFeatureService featureService,
IApplicationCacheService applicationCacheService,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmail2faSessionTokeFactory,
ICurrentContext currentContext) : ITwoFactorAuthenticationValidator
{
private readonly IUserService _userService = userService;
private readonly UserManager<User> _userManager = userManager;
private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider = organizationDuoWebTokenProvider;
private readonly ITemporaryDuoWebV4SDKService _duoWebV4SDKService = duoWebV4SDKService;
private readonly IFeatureService _featureService = featureService;
private readonly IApplicationCacheService _applicationCacheService = applicationCacheService;
private readonly IOrganizationUserRepository _organizationUserRepository = organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository = organizationRepository;
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmail2faSessionTokeFactory = ssoEmail2faSessionTokeFactory;
private readonly ICurrentContext _currentContext = currentContext;
public async Task<Tuple<bool, Organization>> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request)
{
if (request.GrantType == "client_credentials" || request.GrantType == "webauthn")
{
/*
Do not require MFA for api key logins.
We consider Fido2 userVerification a second factor, so we don't require a second factor here.
*/
return new Tuple<bool, Organization>(false, null);
}
var individualRequired = _userManager.SupportsUserTwoFactor &&
await _userManager.GetTwoFactorEnabledAsync(user) &&
(await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0;
Organization firstEnabledOrg = null;
var orgs = (await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)).ToList();
if (orgs.Count > 0)
{
var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
var twoFactorOrgs = orgs.Where(o => OrgUsing2fa(orgAbilities, o.Id));
if (twoFactorOrgs.Any())
{
var userOrgs = await _organizationRepository.GetManyByUserIdAsync(user.Id);
firstEnabledOrg = userOrgs.FirstOrDefault(
o => orgs.Any(om => om.Id == o.Id) && o.TwoFactorIsEnabled());
}
}
return new Tuple<bool, Organization>(individualRequired || firstEnabledOrg != null, firstEnabledOrg);
}
public async Task<Dictionary<string, object>> BuildTwoFactorResultAsync(User user, Organization organization)
{
var enabledProviders = await GetEnabledTwoFactorProvidersAsync(user, organization);
if (enabledProviders.Count == 0)
{
return null;
}
var providers = new Dictionary<string, Dictionary<string, object>>();
foreach (var provider in enabledProviders)
{
var twoFactorParams = await BuildTwoFactorParams(organization, user, provider.Key, provider.Value);
providers.Add(((byte)provider.Key).ToString(), twoFactorParams);
}
var twoFactorResultDict = new Dictionary<string, object>
{
{ "TwoFactorProviders", null },
{ "TwoFactorProviders2", providers }, // backwards compatibility
};
// If we have email as a 2FA provider, we might need an SsoEmail2fa Session Token
if (enabledProviders.Any(p => p.Key == TwoFactorProviderType.Email))
{
twoFactorResultDict.Add("SsoEmail2faSessionToken",
_ssoEmail2faSessionTokeFactory.Protect(new SsoEmail2faSessionTokenable(user)));
twoFactorResultDict.Add("Email", user.Email);
}
if (enabledProviders.Count == 1 && enabledProviders.First().Key == TwoFactorProviderType.Email)
{
// Send email now if this is their only 2FA method
await _userService.SendTwoFactorEmailAsync(user);
}
return twoFactorResultDict;
}
public async Task<bool> VerifyTwoFactor(
User user,
Organization organization,
TwoFactorProviderType type,
string token)
{
if (organization != null && type == TwoFactorProviderType.OrganizationDuo)
{
if (organization.TwoFactorProviderIsEnabled(type))
{
// DUO SDK v4 Update: try to validate the token - PM-5156 addresses tech debt
if (_featureService.IsEnabled(FeatureFlagKeys.DuoRedirect))
{
if (!token.Contains(':'))
{
// We have to send the provider to the DuoWebV4SDKService to create the DuoClient
var provider = organization.GetTwoFactorProvider(TwoFactorProviderType.OrganizationDuo);
return await _duoWebV4SDKService.ValidateAsync(token, provider, user);
}
}
return await _organizationDuoWebTokenProvider.ValidateAsync(token, organization, user);
}
return false;
}
switch (type)
{
case TwoFactorProviderType.Authenticator:
case TwoFactorProviderType.Email:
case TwoFactorProviderType.Duo:
case TwoFactorProviderType.YubiKey:
case TwoFactorProviderType.WebAuthn:
case TwoFactorProviderType.Remember:
if (type != TwoFactorProviderType.Remember &&
!await _userService.TwoFactorProviderIsEnabledAsync(type, user))
{
return false;
}
// DUO SDK v4 Update: try to validate the token - PM-5156 addresses tech debt
if (_featureService.IsEnabled(FeatureFlagKeys.DuoRedirect))
{
if (type == TwoFactorProviderType.Duo)
{
if (!token.Contains(':'))
{
// We have to send the provider to the DuoWebV4SDKService to create the DuoClient
var provider = user.GetTwoFactorProvider(TwoFactorProviderType.Duo);
return await _duoWebV4SDKService.ValidateAsync(token, provider, user);
}
}
}
return await _userManager.VerifyTwoFactorTokenAsync(user,
CoreHelpers.CustomProviderName(type), token);
default:
return false;
}
}
private async Task<List<KeyValuePair<TwoFactorProviderType, TwoFactorProvider>>> GetEnabledTwoFactorProvidersAsync(
User user, Organization organization)
{
var enabledProviders = new List<KeyValuePair<TwoFactorProviderType, TwoFactorProvider>>();
var organizationTwoFactorProviders = organization?.GetTwoFactorProviders();
if (organizationTwoFactorProviders != null)
{
enabledProviders.AddRange(
organizationTwoFactorProviders.Where(
p => (p.Value?.Enabled ?? false) && organization.Use2fa));
}
var userTwoFactorProviders = user.GetTwoFactorProviders();
var userCanAccessPremium = await _userService.CanAccessPremium(user);
if (userTwoFactorProviders != null)
{
enabledProviders.AddRange(
userTwoFactorProviders.Where(p =>
// Providers that do not require premium
(p.Value.Enabled && !TwoFactorProvider.RequiresPremium(p.Key)) ||
// Providers that require premium and the User has Premium
(p.Value.Enabled && TwoFactorProvider.RequiresPremium(p.Key) && userCanAccessPremium)));
}
return enabledProviders;
}
/// <summary>
/// Builds the parameters for the two-factor authentication
/// </summary>
/// <param name="organization">We need the organization for Organization Duo Provider type</param>
/// <param name="user">The user for which the token is being generated</param>
/// <param name="type">Provider Type</param>
/// <param name="provider">Raw data that is used to create the response</param>
/// <returns>a dictionary with the correct provider configuration or null if the provider is not configured properly</returns>
private async Task<Dictionary<string, object>> BuildTwoFactorParams(Organization organization, User user,
TwoFactorProviderType type, TwoFactorProvider provider)
{
// We will always return this dictionary. If none of the criteria is met then it will return null.
var twoFactorParams = new Dictionary<string, object>();
// OrganizationDuo is odd since it doesn't use the UserManager built-in TwoFactor flows
/*
Note: Duo is in the midst of being updated to use the UserManager built-in TwoFactor class
in the future the `AuthUrl` will be the generated "token" - PM-8107
*/
if (type == TwoFactorProviderType.OrganizationDuo &&
await _organizationDuoWebTokenProvider.CanGenerateTwoFactorTokenAsync(organization))
{
twoFactorParams.Add("Host", provider.MetaData["Host"]);
twoFactorParams.Add("AuthUrl", await _duoWebV4SDKService.GenerateAsync(provider, user));
return twoFactorParams;
}
// Individual 2FA providers use the UserManager built-in TwoFactor flow so we can generate the token before building the params
var token = await _userManager.GenerateTwoFactorTokenAsync(user,
CoreHelpers.CustomProviderName(type));
switch (type)
{
/*
Note: Duo is in the midst of being updated to use the UserManager built-in TwoFactor class
in the future the `AuthUrl` will be the generated "token" - PM-8107
*/
case TwoFactorProviderType.Duo:
twoFactorParams.Add("Host", provider.MetaData["Host"]);
twoFactorParams.Add("AuthUrl", await _duoWebV4SDKService.GenerateAsync(provider, user));
break;
case TwoFactorProviderType.WebAuthn:
if (token != null)
{
twoFactorParams = JsonSerializer.Deserialize<Dictionary<string, object>>(token);
}
break;
case TwoFactorProviderType.Email:
var twoFactorEmail = (string)provider.MetaData["Email"];
var redactedEmail = CoreHelpers.RedactEmailAddress(twoFactorEmail);
twoFactorParams.Add("Email", redactedEmail);
break;
case TwoFactorProviderType.YubiKey:
twoFactorParams.Add("Nfc", (bool)provider.MetaData["Nfc"]);
break;
}
// return null if the dictionary is empty
return twoFactorParams.Count > 0 ? twoFactorParams : null;
}
private bool OrgUsing2fa(IDictionary<Guid, OrganizationAbility> orgAbilities, Guid orgId)
{
return orgAbilities != null && orgAbilities.ContainsKey(orgId) &&
orgAbilities[orgId].Enabled && orgAbilities[orgId].Using2fa;
}
}

View File

@ -1,10 +1,8 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin; using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
@ -19,7 +17,7 @@ using Duende.IdentityServer.Validation;
using Fido2NetLib; using Fido2NetLib;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace Bit.Identity.IdentityServer; namespace Bit.Identity.IdentityServer.RequestValidators;
public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidationContext>, IExtensionGrantValidator public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidationContext>, IExtensionGrantValidator
{ {
@ -34,11 +32,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
IUserService userService, IUserService userService,
IEventService eventService, IEventService eventService,
IDeviceValidator deviceValidator, IDeviceValidator deviceValidator,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
ITemporaryDuoWebV4SDKService duoWebV4SDKService,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IApplicationCacheService applicationCacheService,
IMailService mailService, IMailService mailService,
ILogger<CustomTokenRequestValidator> logger, ILogger<CustomTokenRequestValidator> logger,
ICurrentContext currentContext, ICurrentContext currentContext,
@ -46,16 +41,27 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
IUserRepository userRepository, IUserRepository userRepository,
IPolicyService policyService, IPolicyService policyService,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector, IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector,
IFeatureService featureService, IFeatureService featureService,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand
) )
: base(userManager, userService, eventService, deviceValidator, : base(
organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository, userManager,
applicationCacheService, mailService, logger, currentContext, globalSettings, userService,
userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, userDecryptionOptionsBuilder) eventService,
deviceValidator,
twoFactorAuthenticationValidator,
organizationUserRepository,
mailService,
logger,
currentContext,
globalSettings,
userRepository,
policyService,
featureService,
ssoConfigRepository,
userDecryptionOptionsBuilder)
{ {
_assertionOptionsDataProtector = assertionOptionsDataProtector; _assertionOptionsDataProtector = assertionOptionsDataProtector;
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand; _assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
@ -122,12 +128,6 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
return context.Result.Subject; return context.Result.Subject;
} }
protected override Task<Tuple<bool, Organization>> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request)
{
// We consider Fido2 userVerification a second factor, so we don't require a second factor here.
return Task.FromResult(new Tuple<bool, Organization>(false, null));
}
protected override void SetTwoFactorResult(ExtensionGrantValidationContext context, protected override void SetTwoFactorResult(ExtensionGrantValidationContext context,
Dictionary<string, object> customResponse) Dictionary<string, object> customResponse)
{ {

View File

@ -3,6 +3,7 @@ using Bit.Core.IdentityServer;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Identity.IdentityServer; using Bit.Identity.IdentityServer;
using Bit.Identity.IdentityServer.RequestValidators;
using Bit.SharedWeb.Utilities; using Bit.SharedWeb.Utilities;
using Duende.IdentityServer.ResponseHandling; using Duende.IdentityServer.ResponseHandling;
using Duende.IdentityServer.Services; using Duende.IdentityServer.Services;
@ -21,6 +22,7 @@ public static class ServiceCollectionExtensions
services.AddTransient<IAuthorizationCodeStore, AuthorizationCodeStore>(); services.AddTransient<IAuthorizationCodeStore, AuthorizationCodeStore>();
services.AddTransient<IUserDecryptionOptionsBuilder, UserDecryptionOptionsBuilder>(); services.AddTransient<IUserDecryptionOptionsBuilder, UserDecryptionOptionsBuilder>();
services.AddTransient<IDeviceValidator, DeviceValidator>(); services.AddTransient<IDeviceValidator, DeviceValidator>();
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
var identityServerBuilder = services var identityServerBuilder = services

View File

@ -196,8 +196,7 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
return results.SingleOrDefault(); return results.SingleOrDefault();
} }
} }
public async Task<Tuple<OrganizationUserUserDetails?, ICollection<CollectionAccessSelection>>> public async Task<(OrganizationUserUserDetails? OrganizationUser, ICollection<CollectionAccessSelection> Collections)> GetDetailsByIdWithCollectionsAsync(Guid id)
GetDetailsByIdWithCollectionsAsync(Guid id)
{ {
using (var connection = new SqlConnection(ConnectionString)) using (var connection = new SqlConnection(ConnectionString))
{ {
@ -206,9 +205,9 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
new { Id = id }, new { Id = id },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
var user = (await results.ReadAsync<OrganizationUserUserDetails>()).SingleOrDefault(); var organizationUserUserDetails = (await results.ReadAsync<OrganizationUserUserDetails>()).SingleOrDefault();
var collections = (await results.ReadAsync<CollectionAccessSelection>()).ToList(); var collections = (await results.ReadAsync<CollectionAccessSelection>()).ToList();
return new Tuple<OrganizationUserUserDetails?, ICollection<CollectionAccessSelection>>(user, collections); return (organizationUserUserDetails, collections);
} }
} }

View File

@ -248,7 +248,7 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
} }
} }
public async Task<Tuple<OrganizationUserUserDetails, ICollection<CollectionAccessSelection>>> GetDetailsByIdWithCollectionsAsync(Guid id) public async Task<(OrganizationUserUserDetails? OrganizationUser, ICollection<CollectionAccessSelection> Collections)> GetDetailsByIdWithCollectionsAsync(Guid id)
{ {
var organizationUserUserDetails = await GetDetailsByIdAsync(id); var organizationUserUserDetails = await GetDetailsByIdAsync(id);
using (var scope = ServiceScopeFactory.CreateScope()) using (var scope = ServiceScopeFactory.CreateScope())
@ -265,7 +265,7 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
HidePasswords = cu.HidePasswords, HidePasswords = cu.HidePasswords,
Manage = cu.Manage Manage = cu.Manage
}).ToListAsync(); }).ToListAsync();
return new Tuple<OrganizationUserUserDetails, ICollection<CollectionAccessSelection>>(organizationUserUserDetails, collections); return (organizationUserUserDetails, collections);
} }
} }

View File

@ -21,6 +21,10 @@ public class DeviceEntityTypeConfiguration : IEntityTypeConfiguration<Device>
.HasIndex(d => d.Identifier) .HasIndex(d => d.Identifier)
.IsClustered(false); .IsClustered(false);
builder.Property(c => c.Active)
.ValueGeneratedNever()
.HasDefaultValue(true);
builder.ToTable(nameof(Device)); builder.ToTable(nameof(Device));
} }
} }

View File

@ -7,7 +7,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="6.8.1" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="6.9.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -274,6 +274,11 @@ public static class ServiceCollectionExtensions
services.AddKeyedSingleton<IPushNotificationService, RelayPushNotificationService>("implementation"); services.AddKeyedSingleton<IPushNotificationService, RelayPushNotificationService>("implementation");
services.AddSingleton<IPushRegistrationService, RelayPushRegistrationService>(); services.AddSingleton<IPushRegistrationService, RelayPushRegistrationService>();
} }
else
{
services.AddSingleton<IPushRegistrationService, NoopPushRegistrationService>();
}
if (CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey) && if (CoreHelpers.SettingHasValue(globalSettings.InternalIdentityKey) &&
CoreHelpers.SettingHasValue(globalSettings.BaseServiceUri.InternalNotifications)) CoreHelpers.SettingHasValue(globalSettings.BaseServiceUri.InternalNotifications))
{ {
@ -290,10 +295,6 @@ public static class ServiceCollectionExtensions
services.AddKeyedSingleton<IPushNotificationService, AzureQueuePushNotificationService>("implementation"); services.AddKeyedSingleton<IPushNotificationService, AzureQueuePushNotificationService>("implementation");
} }
} }
else
{
services.AddSingleton<IPushRegistrationService, NoopPushRegistrationService>();
}
if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Mail.ConnectionString)) if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Mail.ConnectionString))
{ {

View File

@ -9,7 +9,8 @@
@RevisionDate DATETIME2(7), @RevisionDate DATETIME2(7),
@EncryptedUserKey VARCHAR(MAX) = NULL, @EncryptedUserKey VARCHAR(MAX) = NULL,
@EncryptedPublicKey VARCHAR(MAX) = NULL, @EncryptedPublicKey VARCHAR(MAX) = NULL,
@EncryptedPrivateKey VARCHAR(MAX) = NULL @EncryptedPrivateKey VARCHAR(MAX) = NULL,
@Active BIT = 1
AS AS
BEGIN BEGIN
SET NOCOUNT ON SET NOCOUNT ON
@ -26,7 +27,8 @@ BEGIN
[RevisionDate], [RevisionDate],
[EncryptedUserKey], [EncryptedUserKey],
[EncryptedPublicKey], [EncryptedPublicKey],
[EncryptedPrivateKey] [EncryptedPrivateKey],
[Active]
) )
VALUES VALUES
( (
@ -40,6 +42,7 @@ BEGIN
@RevisionDate, @RevisionDate,
@EncryptedUserKey, @EncryptedUserKey,
@EncryptedPublicKey, @EncryptedPublicKey,
@EncryptedPrivateKey @EncryptedPrivateKey,
@Active
) )
END END

View File

@ -1,12 +0,0 @@
CREATE PROCEDURE [dbo].[Device_DeleteById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
DELETE
FROM
[dbo].[Device]
WHERE
[Id] = @Id
END

View File

@ -9,7 +9,8 @@
@RevisionDate DATETIME2(7), @RevisionDate DATETIME2(7),
@EncryptedUserKey VARCHAR(MAX) = NULL, @EncryptedUserKey VARCHAR(MAX) = NULL,
@EncryptedPublicKey VARCHAR(MAX) = NULL, @EncryptedPublicKey VARCHAR(MAX) = NULL,
@EncryptedPrivateKey VARCHAR(MAX) = NULL @EncryptedPrivateKey VARCHAR(MAX) = NULL,
@Active BIT = 1
AS AS
BEGIN BEGIN
SET NOCOUNT ON SET NOCOUNT ON
@ -26,7 +27,8 @@ BEGIN
[RevisionDate] = @RevisionDate, [RevisionDate] = @RevisionDate,
[EncryptedUserKey] = @EncryptedUserKey, [EncryptedUserKey] = @EncryptedUserKey,
[EncryptedPublicKey] = @EncryptedPublicKey, [EncryptedPublicKey] = @EncryptedPublicKey,
[EncryptedPrivateKey] = @EncryptedPrivateKey [EncryptedPrivateKey] = @EncryptedPrivateKey,
[Active] = @Active
WHERE WHERE
[Id] = @Id [Id] = @Id
END END

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