1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-29 13:25:17 +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,
"tools": {
"swashbuckle.aspnetcore.cli": {
"version": "6.8.1",
"version": "6.9.0",
"commands": ["swagger"]
},
"dotnet-ef": {

View File

@ -29,7 +29,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Check out branch
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
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' }}
steps:
- name: Checkout
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
@ -107,7 +107,7 @@ jobs:
devops-alerts-slack-webhook-url"
- 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:
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out target ref
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.target_ref }}
@ -62,7 +62,7 @@ jobs:
version: ${{ inputs.version_number_override }}
- name: Check out branch
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
@ -135,13 +135,61 @@ jobs:
git config --local user.email "actions@github.com"
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
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
- 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: |
git pull -pt
git push
PR_URL=$(gh pr create --title "$TITLE" \
--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:
@ -150,10 +198,10 @@ jobs:
needs: bump_version
steps:
- name: Check out main branch
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
- name: Install xmllint
run: |
sudo apt-get update
@ -189,7 +237,7 @@ jobs:
RC_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
echo "rc_version=$RC_VERSION" >> $GITHUB_OUTPUT
fi
- name: Configure Git
run: |
git config --local user.email "actions@github.com"

View File

@ -26,7 +26,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
@ -46,7 +46,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }}
- 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:
sarif_file: cx_result.sarif
@ -60,19 +60,19 @@ jobs:
steps:
- name: Set up JDK 17
uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.4.0
uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with:
java-version: 17
distribution: "zulu"
- name: Check out repo
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
- name: Install SonarCloud scanner
run: dotnet tool install dotnet-sonarscanner -g

View File

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

View File

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

View File

@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2024.10.1</Version>
<Version>2024.11.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<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)
{
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);
if (owner == null)
@ -64,27 +94,10 @@ public class CreateProviderCommand : ICreateProviderCommand
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 _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email);
}
public async Task CreateResellerAsync(Provider provider)
{
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
return provider.Id;
}
private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status)
@ -95,9 +108,9 @@ public class CreateProviderCommand : ICreateProviderCommand
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,
PlanType = planType,
@ -105,5 +118,6 @@ public class CreateProviderCommand : ICreateProviderCommand
PurchasedSeats = 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.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -379,42 +380,23 @@ public class ProviderBillingService(
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
var teamsProviderPlan =
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
if (teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
foreach (var providerPlan in providerPlans)
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Teams plan", provider.Id);
var plan = StaticStore.GetPlan(providerPlan.PlanType);
throw new BillingException();
if (!providerPlan.IsConfigured())
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", provider.Id, plan.Name);
throw new BillingException();
}
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = plan.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = providerPlan.SeatMinimum
});
}
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = teamsPlan.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = teamsProviderPlan.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
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
@ -456,144 +438,142 @@ public class ProviderBillingService(
}
}
public async Task UpdateSeatMinimums(
Provider provider,
int enterpriseSeatMinimum,
int teamsSeatMinimum)
public async Task ChangePlan(ChangeProviderPlanCommand command)
{
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.");
}
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 providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var providerPlans = await providerPlanRepository.GetByProviderId(command.Id);
var enterpriseProviderPlan =
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
if (enterpriseProviderPlan.SeatMinimum != enterpriseSeatMinimum)
foreach (var newPlanConfiguration in command.Configuration)
{
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager
.StripeProviderPortalSeatPlanId;
var providerPlan =
providerPlans.Single(providerPlan => providerPlan.PlanType == newPlanConfiguration.Plan);
var enterpriseSubscriptionItem = subscription.Items.First(item => item.Price.Id == enterprisePriceId);
if (enterpriseProviderPlan.PurchasedSeats == 0)
if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum)
{
if (enterpriseProviderPlan.AllocatedSeats > enterpriseSeatMinimum)
{
enterpriseProviderPlan.PurchasedSeats =
enterpriseProviderPlan.AllocatedSeats - enterpriseSeatMinimum;
var priceId = StaticStore.GetPlan(newPlanConfiguration.Plan).PasswordManager
.StripeProviderPortalSeatPlanId;
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
if (providerPlan.PurchasedSeats == 0)
{
if (providerPlan.AllocatedSeats > newPlanConfiguration.SeatsMinimum)
{
Id = enterpriseSubscriptionItem.Id,
Price = enterprisePriceId,
Quantity = enterpriseProviderPlan.AllocatedSeats
});
providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - newPlanConfiguration.SeatsMinimum;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = subscriptionItem.Id,
Price = priceId,
Quantity = providerPlan.AllocatedSeats
});
}
else
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = subscriptionItem.Id,
Price = priceId,
Quantity = newPlanConfiguration.SeatsMinimum
});
}
}
else
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
if (newPlanConfiguration.SeatsMinimum <= totalSeats)
{
Id = enterpriseSubscriptionItem.Id,
Price = enterprisePriceId,
Quantity = enterpriseSeatMinimum
});
providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum;
}
else
{
providerPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = subscriptionItem.Id,
Price = priceId,
Quantity = newPlanConfiguration.SeatsMinimum
});
}
}
providerPlan.SeatMinimum = newPlanConfiguration.SeatsMinimum;
await providerPlanRepository.ReplaceAsync(providerPlan);
}
else
{
var totalEnterpriseSeats = enterpriseProviderPlan.SeatMinimum + enterpriseProviderPlan.PurchasedSeats;
if (enterpriseSeatMinimum <= totalEnterpriseSeats)
{
enterpriseProviderPlan.PurchasedSeats = totalEnterpriseSeats - enterpriseSeatMinimum;
}
else
{
enterpriseProviderPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = enterpriseSubscriptionItem.Id,
Price = enterprisePriceId,
Quantity = enterpriseSeatMinimum
});
}
}
enterpriseProviderPlan.SeatMinimum = enterpriseSeatMinimum;
await providerPlanRepository.ReplaceAsync(enterpriseProviderPlan);
}
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)
{
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId,
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.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@ -19,23 +20,30 @@ public class CreateProviderCommandTests
[Theory, BitAutoData]
public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.Msp;
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateMspAsync(provider, default, default, default));
// Assert
Assert.Contains("Invalid owner.", exception.Message);
}
[Theory, BitAutoData]
public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.Msp;
var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user);
// Act
await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
}
@ -43,11 +51,52 @@ public class CreateProviderCommandTests
[Theory, BitAutoData]
public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.Reseller;
// Act
await sutProvider.Sut.CreateResellerAsync(provider);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(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.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -1011,26 +1012,192 @@ public class ProviderBillingServiceTests
#endregion
#region UpdateSeatMinimums
#region ChangePlan
[Theory, BitAutoData]
public async Task UpdateSeatMinimums_NullProvider_ThrowsArgumentNullException(
SutProvider<ProviderBillingService> sutProvider) =>
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.UpdateSeatMinimums(null, 0, 0));
public async Task ChangePlan_NullProviderPlan_ThrowsBadRequestException(
ChangeProviderPlanCommand command,
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]
public async Task UpdateSeatMinimums_NegativeSeatMinimum_ThrowsBadRequestException(
Provider provider,
SutProvider<ProviderBillingService> sutProvider) =>
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSeatMinimums(provider, -10, 100));
SutProvider<ProviderBillingService> sutProvider)
{
// 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]
public async Task UpdateSeatMinimums_NoPurchasedSeats_AllocatedHigherThanIncomingMinimum_UpdatesPurchasedSeats_SyncsStripeWithNewSeatMinimum(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var providerPlanRepository = sutProvider.GetDependency<IProviderPlanRepository>();
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
const string enterpriseLineItemId = "enterprise_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>
{
@ -1066,10 +1235,21 @@ public class ProviderBillingServiceTests
new() { PlanType = PlanType.TeamsMonthly, SeatMinimum = 30, PurchasedSeats = 0, AllocatedSeats = 25 }
};
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
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>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 30));
@ -1091,8 +1271,11 @@ public class ProviderBillingServiceTests
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
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 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>
{
@ -1130,8 +1313,18 @@ public class ProviderBillingServiceTests
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>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70));
@ -1153,8 +1346,11 @@ public class ProviderBillingServiceTests
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
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 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>
{
@ -1192,8 +1388,18 @@ public class ProviderBillingServiceTests
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>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10));
@ -1209,8 +1415,11 @@ public class ProviderBillingServiceTests
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
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 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>
{
@ -1248,8 +1457,18 @@ public class ProviderBillingServiceTests
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>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0));
@ -1271,8 +1490,11 @@ public class ProviderBillingServiceTests
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
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 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>
{
@ -1310,8 +1532,18 @@ public class ProviderBillingServiceTests
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>(
providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly && providerPlan.SeatMinimum == 70));

View File

@ -9,7 +9,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</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="NSubstitute" Version="$(NSubstituteVersion)" />
<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.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
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,
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]
[ValidateAntiForgeryToken]
[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)
{
@ -128,19 +175,51 @@ public class ProvidersController : Controller
}
var provider = model.ToProvider();
switch (provider.Type)
await _createProviderCommand.CreateMspAsync(
provider,
model.OwnerEmail,
model.TeamsMonthlySeatMinimum,
model.EnterpriseMonthlySeatMinimum);
return RedirectToAction("Edit", new { id = provider.Id });
}
[HttpPost("providers/create/reseller")]
[ValidateAntiForgeryToken]
[RequirePermission(Permission.Provider_Create)]
public async Task<IActionResult> CreateReseller(CreateResellerProviderModel model)
{
if (!ModelState.IsValid)
{
case ProviderType.Msp:
await _createProviderCommand.CreateMspAsync(
provider,
model.OwnerEmail,
model.TeamsMonthlySeatMinimum,
model.EnterpriseMonthlySeatMinimum);
break;
case ProviderType.Reseller:
await _createProviderCommand.CreateResellerAsync(provider);
break;
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 });
}
@ -212,25 +291,39 @@ public class ProvidersController : Controller
var providerPlans = await _providerPlanRepository.GetByProviderId(id);
if (providerPlans.Count == 0)
switch (provider.Type)
{
var newProviderPlans = new List<ProviderPlan>
{
new () { ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = model.TeamsMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 },
new () { ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = model.EnterpriseMonthlySeatMinimum, PurchasedSeats = 0, AllocatedSeats = 0 }
};
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:
{
var existingMoePlan = providerPlans.Single();
foreach (var newProviderPlan in newProviderPlans)
{
await _providerPlanRepository.CreateAsync(newProviderPlan);
}
}
else
{
await _providerBillingService.UpdateSeatMinimums(
provider,
model.EnterpriseMonthlySeatMinimum,
model.TeamsMonthlySeatMinimum);
// 1. Change the plan and take over any old values.
var changeMoePlanCommand = new ChangeProviderPlanCommand(
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;
}
}
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.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.SharedWeb.Utilities;
using Bit.Core.AdminConsole.Enums.Provider;
namespace Bit.Admin.AdminConsole.Models;
public class CreateProviderModel : IValidatableObject
public class CreateProviderModel
{
public CreateProviderModel() { }
[Display(Name = "Provider Type")]
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;
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
Type = provider.Type;
if (Type == ProviderType.MultiOrganizationEnterprise)
{
var plan = providerPlans.SingleOrDefault();
EnterpriseMinimumSeats = plan?.SeatMinimum ?? 0;
Plan = plan?.PlanType;
}
}
[Display(Name = "Billing Email")]
@ -58,13 +65,24 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
[Display(Name = "Provider Type")]
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)
{
existingProvider.BillingEmail = BillingEmail?.ToLowerInvariant().Trim();
existingProvider.BillingPhone = BillingPhone?.ToLowerInvariant().Trim();
existingProvider.Gateway = Gateway;
existingProvider.GatewayCustomerId = GatewayCustomerId;
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
switch (Type)
{
case ProviderType.Msp:
existingProvider.Gateway = Gateway;
existingProvider.GatewayCustomerId = GatewayCustomerId;
existingProvider.GatewaySubscriptionId = GatewaySubscriptionId;
break;
}
return existingProvider;
}
@ -82,6 +100,23 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
yield return new ValidationResult($"The {billingEmailDisplayName} field is required.");
}
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.Core.AdminConsole.Enums.Provider
@using Bit.Core
@model CreateProviderModel
@inject Bit.Core.Services.IFeatureService FeatureService
@{
ViewData["Title"] = "Create Provider";
}
@section Scripts {
<script>
function toggleProviderTypeInfo(value) {
document.querySelectorAll('[id^="info-"]').forEach(el => { el.classList.add('d-none'); });
document.getElementById('info-' + value).classList.remove('d-none');
}
</script>
var providerTypes = Enum.GetValues<ProviderType>()
.OrderBy(x => x.GetDisplayAttribute().Order)
.ToList();
if (!FeatureService.IsEnabled(FeatureFlagKeys.PM12275_MultiOrganizationEnterprises))
{
providerTypes.Remove(ProviderType.MultiOrganizationEnterprise);
}
}
<h1>Create Provider</h1>
<form method="post">
<form method="post" asp-action="Create">
<div asp-validation-summary="All" class="alert alert-danger"></div>
<div class="form-group">
<label asp-for="Type" class="h2"></label>
@foreach(ProviderType providerType in Enum.GetValues(typeof(ProviderType)))
@foreach (var providerType in providerTypes)
{
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">
<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 class="form-group">
<div class="row">
<div class="col">
<div class="form-check">
@Html.RadioButtonFor(m => m.Type, providerType, new { id = $"providerType-{providerTypeValue}", @class = "form-check-input" })
@Html.LabelFor(m => m.Type, providerType.GetDisplayAttribute()?.GetName(), new { @class = "form-check-label align-middle", @for = $"providerType-{providerTypeValue}" })
</div>
</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 class="row">
<div class="col">
@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 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>
<button type="submit" class="btn btn-primary mb-2">Next</button>
</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.Core
@using Bit.Core.AdminConsole.Enums.Provider
@using Bit.Core.Billing.Enums
@using Bit.Core.Billing.Extensions
@using Microsoft.AspNetCore.Mvc.TagHelpers
@inject Bit.Admin.Services.IAccessControlService AccessControlService
@inject Bit.Core.Services.IFeatureService FeatureService
@ -47,60 +50,97 @@
</div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && Model.Provider.IsBillable())
{
<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>
<div class="row">
<div class="col-sm">
<div class="form-group">
<div class="form-group">
<label asp-for="Gateway"></label>
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option>
</select>
switch (Model.Provider.Type)
{
case ProviderType.Msp:
{
<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>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewayCustomerId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId">
<div class="input-group-append">
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank">
<i class="fa fa-external-link"></i>
</a>
<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>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewaySubscriptionId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
<div class="input-group-append">
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank">
<i class="fa fa-external-link"></i>
</a>
<div class="row">
<div class="col-sm">
<div class="form-group">
<div class="form-group">
<label asp-for="Gateway"></label>
<select class="form-control" asp-for="Gateway" asp-items="Html.GetEnumSelectList<Bit.Core.Enums.GatewayType>()">
<option value="">--</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewayCustomerId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewayCustomerId">
<div class="input-group-append">
<a href="@Model.GatewayCustomerUrl" class="btn btn-secondary" target="_blank">
<i class="fa fa-external-link"></i>
</a>
</div>
</div>
</div>
</div>
<div class="col-sm">
<div class="form-group">
<label asp-for="GatewaySubscriptionId"></label>
<div class="input-group">
<input type="text" class="form-control" asp-for="GatewaySubscriptionId">
<div class="input-group-append">
<a href="@Model.GatewaySubscriptionUrl" class="btn btn-secondary" target="_blank">
<i class="fa fa-external-link"></i>
</a>
</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>
@await Html.PartialAsync("Organizations", Model)

View File

@ -4,6 +4,7 @@ using Bit.Admin.Enums;
using Bit.Admin.Models;
using Bit.Admin.Services;
using Bit.Admin.Utilities;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
@ -24,6 +25,8 @@ public class UsersController : Controller
private readonly GlobalSettings _globalSettings;
private readonly IAccessControlService _accessControlService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IUserService _userService;
private readonly IFeatureService _featureService;
public UsersController(
IUserRepository userRepository,
@ -31,7 +34,9 @@ public class UsersController : Controller
IPaymentService paymentService,
GlobalSettings globalSettings,
IAccessControlService accessControlService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserService userService,
IFeatureService featureService)
{
_userRepository = userRepository;
_cipherRepository = cipherRepository;
@ -39,6 +44,8 @@ public class UsersController : Controller
_globalSettings = globalSettings;
_accessControlService = accessControlService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_userService = userService;
_featureService = featureService;
}
[RequirePermission(Permission.User_List_View)]
@ -82,8 +89,8 @@ public class UsersController : Controller
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers));
var verifiedDomain = await AccountDeprovisioningEnabled(user.Id);
return View(UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, verifiedDomain));
}
[SelfHosted(NotSelfHostedOnly = true)]
@ -99,7 +106,8 @@ public class UsersController : Controller
var billingInfo = await _paymentService.GetBillingAsync(user);
var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(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]
@ -153,4 +161,12 @@ public class UsersController : Controller
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,
BillingInfo billingInfo,
BillingHistoryInfo billingHistoryInfo,
GlobalSettings globalSettings)
GlobalSettings globalSettings,
bool? domainVerified
)
{
User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers);
User = UserViewModel.MapViewModel(user, isTwoFactorEnabled, ciphers, domainVerified);
BillingInfo = billingInfo;
BillingHistoryInfo = billingHistoryInfo;

View File

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

View File

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

View File

@ -1,4 +1,4 @@
@model UserViewModel
@model UserViewModel
<dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt>
<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>
<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>
<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.Response.Organizations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
@ -53,6 +52,8 @@ public class OrganizationUsersController : Controller
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IDeleteManagedOrganizationUserAccountCommand _deleteManagedOrganizationUserAccountCommand;
private readonly IGetOrganizationUsersManagementStatusQuery _getOrganizationUsersManagementStatusQuery;
private readonly IFeatureService _featureService;
public OrganizationUsersController(
IOrganizationRepository organizationRepository,
@ -73,7 +74,9 @@ public class OrganizationUsersController : Controller
IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand)
IDeleteManagedOrganizationUserAccountCommand deleteManagedOrganizationUserAccountCommand,
IGetOrganizationUsersManagementStatusQuery getOrganizationUsersManagementStatusQuery,
IFeatureService featureService)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -94,29 +97,34 @@ public class OrganizationUsersController : Controller
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_deleteManagedOrganizationUserAccountCommand = deleteManagedOrganizationUserAccountCommand;
_getOrganizationUsersManagementStatusQuery = getOrganizationUsersManagementStatusQuery;
_featureService = featureService;
}
[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));
if (organizationUser == null || !await _currentContext.ManageUsers(organizationUser.Item1.OrganizationId))
var (organizationUser, collections) = await _organizationUserRepository.GetDetailsByIdWithCollectionsAsync(id);
if (organizationUser == null || !await _currentContext.ManageUsers(organizationUser.OrganizationId))
{
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)
{
response.Groups = await _groupRepository.GetManyIdsByUserIdAsync(organizationUser.Item1.Id);
response.Groups = await _groupRepository.GetManyIdsByUserIdAsync(organizationUser.Id);
}
return response;
}
[HttpGet("mini-details")]
[RequireFeature(FeatureFlagKeys.Pm3478RefactorOrganizationUserApi)]
public async Task<ListResponseModel<OrganizationUserUserMiniDetailsResponseModel>> GetMiniDetails(Guid 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 organizationUsersManagementStatus = await GetManagedByOrganizationStatusAsync(orgId, organizationUsers.Select(o => o.Id));
var responses = organizationUsers
.Select(o =>
{
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;
});
@ -534,7 +544,7 @@ public class OrganizationUsersController : Controller
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("{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))
{
@ -547,19 +557,13 @@ public class OrganizationUsersController : Controller
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);
}
[RequireFeature(FeatureFlagKeys.AccountDeprovisioning)]
[HttpDelete("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))
{
@ -572,12 +576,6 @@ public class OrganizationUsersController : Controller
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);
return new ListResponseModel<OrganizationUserBulkResponseModel>(results.Select(r =>
@ -682,4 +680,15 @@ public class OrganizationUsersController : Controller
return new ListResponseModel<OrganizationUserBulkResponseModel>(result.Select(r =>
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.DataProtection;
using Microsoft.AspNetCore.Mvc;
using AdminConsoleEntities = Bit.Core.AdminConsole.Entities;
namespace Bit.Api.AdminConsole.Controllers;
@ -55,17 +56,16 @@ public class PoliciesController : Controller
}
[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(orgIdGuid))
if (!await _currentContext.ManagePolicies(orgId))
{
throw new NotFoundException();
}
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgIdGuid, (PolicyType)type);
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
if (policy == null)
{
throw new NotFoundException();
return new PolicyResponseModel(new AdminConsoleEntities.Policy() { Type = (PolicyType)type, Enabled = false });
}
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 OrganizationUserDetailsResponseModel(OrganizationUser organizationUser,
public OrganizationUserDetailsResponseModel(
OrganizationUser organizationUser,
bool managedByOrganization,
IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails")
{
ManagedByOrganization = managedByOrganization;
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
}
public OrganizationUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool managedByOrganization,
IEnumerable<CollectionAccessSelection> collections)
: base(organizationUser, "organizationUserDetails")
{
ManagedByOrganization = managedByOrganization;
Collections = collections.Select(c => new SelectionReadOnlyResponseModel(c));
}
public bool ManagedByOrganization { get; set; }
public IEnumerable<SelectionReadOnlyResponseModel> Collections { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
@ -110,7 +117,7 @@ public class OrganizationUserUserMiniDetailsResponseModel : ResponseModel
public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponseModel
{
public OrganizationUserUserDetailsResponseModel(OrganizationUserUserDetails organizationUser,
bool twoFactorEnabled, string obj = "organizationUserUserDetails")
bool twoFactorEnabled, bool managedByOrganization, string obj = "organizationUserUserDetails")
: base(organizationUser, obj)
{
if (organizationUser == null)
@ -127,6 +134,7 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
Groups = organizationUser.Groups;
// Prevent reset password when using key connector.
ResetPasswordEnrolled = ResetPasswordEnrolled && !organizationUser.UsesKeyConnector;
ManagedByOrganization = managedByOrganization;
}
public string Name { get; set; }
@ -134,6 +142,11 @@ public class OrganizationUserUserDetailsResponseModel : OrganizationUserResponse
public string AvatarColor { get; set; }
public bool TwoFactorEnabled { 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<Guid> Groups { get; set; }
}

View File

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

View File

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

View File

@ -148,6 +148,13 @@ public class AccountsController : Controller
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);
}
@ -165,6 +172,13 @@ public class AccountsController : Controller
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,
model.NewMasterPasswordHash, model.Token, model.Key);
if (result.Succeeded)
@ -566,6 +580,13 @@ public class AccountsController : Controller
}
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);
if (result.Succeeded)
{

View File

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

View File

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

View File

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

View File

@ -4,8 +4,10 @@ namespace Bit.Core.AdminConsole.Enums.Provider;
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,
[Display(ShortName = "Reseller", Name = "Reseller", Description = "Access to clients billing")]
[Display(ShortName = "Reseller", Name = "Reseller", Description = "Access to clients billing", Order = 1000)]
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.Enums;
using Bit.Core.Exceptions;
@ -15,6 +18,9 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
private readonly IDnsResolverService _dnsResolverService;
private readonly IEventService _eventService;
private readonly IGlobalSettings _globalSettings;
private readonly IPolicyService _policyService;
private readonly IFeatureService _featureService;
private readonly IOrganizationService _organizationService;
private readonly ILogger<VerifyOrganizationDomainCommand> _logger;
public VerifyOrganizationDomainCommand(
@ -22,12 +28,18 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
IDnsResolverService dnsResolverService,
IEventService eventService,
IGlobalSettings globalSettings,
IPolicyService policyService,
IFeatureService featureService,
IOrganizationService organizationService,
ILogger<VerifyOrganizationDomainCommand> logger)
{
_organizationDomainRepository = organizationDomainRepository;
_dnsResolverService = dnsResolverService;
_eventService = eventService;
_globalSettings = globalSettings;
_policyService = policyService;
_featureService = featureService;
_organizationService = organizationService;
_logger = logger;
}
@ -102,6 +114,8 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
if (await _dnsResolverService.ResolveAsync(domain.DomainName, domain.Txt))
{
domain.SetVerifiedDate();
await EnableSingleOrganizationPolicyAsync(domain.OrganizationId);
}
}
catch (Exception e)
@ -112,4 +126,13 @@ public class VerifyOrganizationDomainCommand : IVerifyOrganizationDomainCommand
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
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
@ -10,12 +9,10 @@ public class OrganizationUserUserDetailsAuthorizationHandler
: AuthorizationHandler<OrganizationUserUserDetailsOperationRequirement, OrganizationScope>
{
private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
public OrganizationUserUserDetailsAuthorizationHandler(ICurrentContext currentContext, IFeatureService featureService)
public OrganizationUserUserDetailsAuthorizationHandler(ICurrentContext currentContext)
{
_currentContext = currentContext;
_featureService = featureService;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
@ -37,29 +34,6 @@ public class OrganizationUserUserDetailsAuthorizationHandler
}
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
var organization = _currentContext.GetOrganization(organizationId);

View File

@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Enums;
namespace Bit.Core.AdminConsole.Providers.Interfaces;
@ -6,4 +7,5 @@ public interface ICreateProviderCommand
{
Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats);
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<Tuple<OrganizationUser?, ICollection<CollectionAccessSelection>>> GetByIdWithCollectionsAsync(Guid id);
Task<OrganizationUserUserDetails?> GetDetailsByIdAsync(Guid id);
Task<Tuple<OrganizationUserUserDetails?, ICollection<CollectionAccessSelection>>>
GetDetailsByIdWithCollectionsAsync(Guid id);
Task<(OrganizationUserUserDetails? OrganizationUser, ICollection<CollectionAccessSelection> Collections)> GetDetailsByIdWithCollectionsAsync(Guid id);
Task<ICollection<OrganizationUserUserDetails>> GetManyDetailsByOrganizationAsync(Guid organizationId, bool includeGroups = false, bool includeCollections = false);
Task<ICollection<OrganizationUserOrganizationDetails>> GetManyDetailsByUserAsync(Guid userId,
OrganizationUserStatusType? status = null);

View File

@ -4,8 +4,4 @@ public interface IOrganizationDomainService
{
Task ValidateOrganizationsDomainAsync();
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)
{
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);

View File

@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
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.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
@ -32,6 +33,7 @@ public class PolicyService : IPolicyService
private readonly IFeatureService _featureService;
private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
public PolicyService(
IApplicationCacheService applicationCacheService,
@ -45,7 +47,8 @@ public class PolicyService : IPolicyService
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IFeatureService featureService,
ISavePolicyCommand savePolicyCommand,
IRemoveOrganizationUserCommand removeOrganizationUserCommand)
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery)
{
_applicationCacheService = applicationCacheService;
_eventService = eventService;
@ -59,6 +62,7 @@ public class PolicyService : IPolicyService
_featureService = featureService;
_savePolicyCommand = savePolicyCommand;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
}
public async Task SaveAsync(Policy policy, Guid? savingUserId)
@ -239,6 +243,7 @@ public class PolicyService : IPolicyService
case PolicyType.SingleOrg:
if (!policy.Enabled)
{
await HasVerifiedDomainsAsync(org);
await RequiredBySsoAsync(org);
await RequiredByVaultTimeoutAsync(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)
{
await _policyRepository.UpsertAsync(policy);

View File

@ -6,6 +6,14 @@ using Bit.Core.Utilities;
namespace Bit.Core.Auth.Models.Api.Request.Accounts;
using System.ComponentModel.DataAnnotations;
public enum RegisterFinishTokenType : byte
{
EmailVerification = 1,
OrganizationInvite = 2,
OrgSponsoredFreeFamilyPlan = 3,
EmergencyAccessInvite = 4,
ProviderInvite = 5,
}
public class RegisterFinishRequestModel : IValidatableObject
{
@ -36,6 +44,10 @@ public class RegisterFinishRequestModel : IValidatableObject
public string? AcceptEmergencyAccessInviteToken { get; set; }
public Guid? AcceptEmergencyAccessId { get; set; }
public string? ProviderInviteToken { get; set; }
public Guid? ProviderUserId { get; set; }
public User ToUser()
{
var user = new User
@ -54,6 +66,32 @@ public class RegisterFinishRequestModel : IValidatableObject
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)
{

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,
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<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
private readonly IDataProtector _organizationServiceDataProtector;
private readonly IDataProtector _providerServiceDataProtector;
private readonly ICurrentContext _currentContext;
@ -75,6 +76,8 @@ public class RegisterUserCommand : IRegisterUserCommand
_validateRedemptionTokenCommand = validateRedemptionTokenCommand;
_emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory;
_providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
}
@ -303,6 +306,25 @@ public class RegisterUserCommand : IRegisterUserCommand
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()
{
// 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)
{

View File

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

View File

@ -8,6 +8,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Migration.Models;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
@ -307,7 +308,14 @@ public class ProviderMigrator(
.FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)?
.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(
"CB: Updated Stripe subscription for provider ({ProviderID}) with current seat minimums", provider.Id);
@ -325,13 +333,16 @@ public class ProviderMigrator(
var organizationCancellationCredit = organizationCustomers.Sum(customer => customer.Balance);
await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId,
new CustomerBalanceTransactionCreateOptions
{
Amount = organizationCancellationCredit,
Currency = "USD",
Description = "Unused, prorated time for client organization subscriptions."
});
if (organizationCancellationCredit != 0)
{
await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId,
new CustomerBalanceTransactionCreateOptions
{
Amount = organizationCancellationCredit,
Currency = "USD",
Description = "Unused, prorated time for client organization subscriptions."
});
}
var migrationRecords = await Task.WhenAll(organizations.Select(organization =>
clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id)));

View File

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

View File

@ -87,7 +87,9 @@ public record EnterprisePlan : Plan
AdditionalStoragePricePerGb = 4;
StripeStoragePlanId = "storage-gb-annually";
StripeSeatPlanId = "2023-enterprise-org-seat-annually";
StripeProviderPortalSeatPlanId = "password-manager-provider-portal-enterprise-annually-2024";
SeatPrice = 72;
ProviderPortalSeatPrice = 72;
}
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.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Models.Business;
using Stripe;
@ -89,8 +90,12 @@ public interface IProviderBillingService
Task<Subscription> SetupSubscription(
Provider provider);
Task UpdateSeatMinimums(
Provider provider,
int enterpriseSeatMinimum,
int teamsSeatMinimum);
/// <summary>
/// Changes the assigned provider plan for the provider.
/// </summary>
/// <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.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
@ -27,7 +26,6 @@ public class OrganizationBillingService(
IGlobalSettings globalSettings,
ILogger<OrganizationBillingService> logger,
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IOrganizationBillingService
@ -64,18 +62,18 @@ public class OrganizationBillingService(
return null;
}
var customer = await subscriberService.GetCustomer(organization, new CustomerGetOptions
{
Expand = ["discount.coupon.applies_to"]
});
var customer = await subscriberService.GetCustomer(organization,
new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] });
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 isSubscriptionUnpaid = IsSubscriptionUnpaid(subscription);
return new OrganizationMetadata(isEligibleForSelfHost, isOnSecretsManagerStandalone);
return new OrganizationMetadata(isEligibleForSelfHost, isManaged, isOnSecretsManagerStandalone,
isSubscriptionUnpaid);
}
public async Task UpdatePaymentMethod(
@ -339,26 +337,12 @@ public class OrganizationBillingService(
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
}
private async Task<bool> IsEligibleForSelfHost(
Organization organization,
Subscription? organizationSubscription)
private static bool IsEligibleForSelfHost(
Organization organization)
{
if (organization.Status != OrganizationStatusType.Managed)
{
return organization.Plan.Contains("Families") ||
organization.Plan.Contains("Enterprise") && IsActive(organizationSubscription);
}
var eligibleSelfHostPlans = StaticStore.Plans.Where(plan => plan.HasSelfHost).Select(plan => plan.Type);
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
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;
return eligibleSelfHostPlans.Contains(organization.PlanType);
}
private static bool IsOnSecretsManagerStandalone(
@ -392,5 +376,16 @@ public class OrganizationBillingService(
return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any();
}
private static bool IsSubscriptionUnpaid(Subscription subscription)
{
if (subscription == null)
{
return false;
}
return subscription.Status == "unpaid";
}
#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 ItemShare = "item-share";
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 EnableConsolidatedBilling = "enable-consolidated-billing";
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 PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
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 BlockLegacyUsers = "block-legacy-users";
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 StorageReseedRefactor = "storage-reseed-refactor";
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 AccessIntelligence = "pm-13227-access-intelligence";
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 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()
{
@ -163,7 +163,6 @@ public static class FeatureFlagKeys
return new Dictionary<string, string>()
{
{ DuoRedirect, "true" },
{ BulkDeviceApproval, "true" },
{ CipherKeyEncryption, "true" },
};
}

View File

@ -21,11 +21,11 @@
<ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.24" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.34" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.401.30" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.40" />
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
<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.Storage.Blobs" Version="12.21.2" />
<PackageReference Include="Azure.Storage.Queues" Version="12.19.1" />
@ -35,22 +35,22 @@
<PackageReference Include="Fido2.AspNet" Version="3.0.1" />
<PackageReference Include="Handlebars.Net" Version="2.1.6" />
<PackageReference Include="MailKit" Version="4.8.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.44.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.45.0" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<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.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="SendGrid" Version="9.29.3" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
<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="Serilog.Sinks.SyslogMessages" Version="4.0.0" />
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
@ -58,8 +58,8 @@
<PackageReference Include="Stripe.net" Version="45.14.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.8" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.5.2" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.6.0" />
</ItemGroup>
<ItemGroup>

View File

@ -38,6 +38,10 @@ public class Device : ITableObject<Guid>
/// </summary>
public string? EncryptedPrivateKey { get; set; }
/// <summary>
/// Whether the device is active for the user.
/// </summary>
public bool Active { get; set; } = true;
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<IGetOrganizationDomainByOrganizationIdQuery, GetOrganizationDomainByOrganizationIdQuery>();
services.AddScoped<IDeleteOrganizationDomainCommand, DeleteOrganizationDomainCommand>();
services.AddScoped<IOrganizationHasVerifiedDomainsQuery, OrganizationHasVerifiedDomainsQuery>();
}
private static void AddOrganizationAuthCommands(this IServiceCollection services)

View File

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

View File

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

View File

@ -14,6 +14,17 @@ public interface IStripeAdapter
CustomerBalanceTransactionCreateOptions options);
Task<Stripe.Subscription> SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions subscriptionCreateOptions);
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<Stripe.Subscription> SubscriptionUpdateAsync(string id, Stripe.SubscriptionUpdateOptions 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());
}
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());
}

View File

@ -112,6 +112,19 @@ public class HandlebarsMailService : IMailService
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)
{
var message = CreateDefaultMessage("Your Email Change", toEmail);

View File

@ -79,6 +79,20 @@ public class StripeAdapter : IStripeAdapter
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,
Stripe.SubscriptionUpdateOptions options = null)
{

View File

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

View File

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

View File

@ -94,6 +94,11 @@ public class NoopMailService : IMailService
return Task.FromResult(0);
}
public Task SendCannotDeleteManagedAccountEmailAsync(string email)
{
return Task.FromResult(0);
}
public Task SendPasswordlessSignInAsync(string returnUrl, string token, string email)
{
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.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Api.Response.Accounts;
@ -149,40 +150,44 @@ public class AccountsController : Controller
IdentityResult identityResult = null;
var delaysEnabled = !_featureService.IsEnabled(FeatureFlagKeys.EmailVerificationDisableTimingDelays);
if (!string.IsNullOrEmpty(model.OrgInviteToken) && model.OrganizationUserId.HasValue)
switch (model.GetTokenType())
{
identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken(user, model.MasterPasswordHash,
model.OrgInviteToken, model.OrganizationUserId);
case RegisterFinishTokenType.EmailVerification:
identityResult =
await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash,
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");
}
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 =
await _registerUserCommand.RegisterUserViaEmailVerificationToken(user, model.MasterPasswordHash,
model.EmailVerificationToken);
return await ProcessRegistrationResult(identityResult, user, delaysEnabled);
}
private async Task<RegisterResponseModel> ProcessRegistrationResult(IdentityResult result, User user, bool delaysEnabled)

View File

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

View File

@ -1,15 +1,10 @@
using System.Security.Claims;
using System.Text.Json;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Entities;
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.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
@ -17,32 +12,26 @@ using Bit.Core.Enums;
using Bit.Core.Identity;
using Bit.Core.Models.Api;
using Bit.Core.Models.Api.Response;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Duende.IdentityServer.Validation;
using Microsoft.AspNetCore.Identity;
namespace Bit.Identity.IdentityServer;
namespace Bit.Identity.IdentityServer.RequestValidators;
public abstract class BaseRequestValidator<T> where T : class
{
private UserManager<User> _userManager;
private readonly IEventService _eventService;
private readonly IDeviceValidator _deviceValidator;
private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider;
private readonly ITemporaryDuoWebV4SDKService _duoWebV4SDKService;
private readonly IOrganizationRepository _organizationRepository;
private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IMailService _mailService;
private readonly ILogger _logger;
private readonly GlobalSettings _globalSettings;
private readonly IUserRepository _userRepository;
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _tokenDataFactory;
protected ICurrentContext CurrentContext { get; }
protected IPolicyService PolicyService { get; }
@ -56,18 +45,14 @@ public abstract class BaseRequestValidator<T> where T : class
IUserService userService,
IEventService eventService,
IDeviceValidator deviceValidator,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
ITemporaryDuoWebV4SDKService duoWebV4SDKService,
IOrganizationRepository organizationRepository,
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
IOrganizationUserRepository organizationUserRepository,
IApplicationCacheService applicationCacheService,
IMailService mailService,
ILogger logger,
ICurrentContext currentContext,
GlobalSettings globalSettings,
IUserRepository userRepository,
IPolicyService policyService,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
@ -76,18 +61,14 @@ public abstract class BaseRequestValidator<T> where T : class
_userService = userService;
_eventService = eventService;
_deviceValidator = deviceValidator;
_organizationDuoWebTokenProvider = organizationDuoWebTokenProvider;
_duoWebV4SDKService = duoWebV4SDKService;
_organizationRepository = organizationRepository;
_twoFactorAuthenticationValidator = twoFactorAuthenticationValidator;
_organizationUserRepository = organizationUserRepository;
_applicationCacheService = applicationCacheService;
_mailService = mailService;
_logger = logger;
CurrentContext = currentContext;
_globalSettings = globalSettings;
PolicyService = policyService;
_userRepository = userRepository;
_tokenDataFactory = tokenDataFactory;
FeatureService = featureService;
SsoConfigRepository = ssoConfigRepository;
UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder;
@ -104,12 +85,6 @@ public abstract class BaseRequestValidator<T> where T : class
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 user = validatorContext.User;
if (!valid)
@ -123,17 +98,37 @@ public abstract class BaseRequestValidator<T> where T : class
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 (!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;
}
// Include Master Password Policy in 2FA response
resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicy(user));
SetTwoFactorResult(context, resultDict);
return;
}
var verified = await VerifyTwoFactor(user, twoFactorOrganization,
twoFactorProviderType, twoFactorToken);
var verified = await _twoFactorAuthenticationValidator
.VerifyTwoFactor(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken);
// 2FA required but request not valid or remember token expired response
if (!verified || isBot)
{
if (twoFactorProviderType != TwoFactorProviderType.Remember)
@ -143,16 +138,20 @@ public abstract class BaseRequestValidator<T> where T : class
}
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;
}
}
else
{
twoFactorRequest = false;
validTwoFactorRequest = false;
twoFactorRemember = false;
twoFactorToken = null;
}
// 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))
{
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);
return;
}
await BuildSuccessResultAsync(user, context, device, twoFactorRequest && twoFactorRemember);
await BuildSuccessResultAsync(user, context, device, validTwoFactorRequest && twoFactorRemember);
}
else
{
@ -238,67 +235,6 @@ public abstract class BaseRequestValidator<T> where T : class
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)
{
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 ClaimsPrincipal GetSubject(T context);
protected virtual async Task<Tuple<bool, Organization>> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request)
{
if (request.GrantType == "client_credentials")
{
// Do not require MFA for api key logins
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);
}
/// <summary>
/// Check if the user is required to authenticate via SSO. If the user requires SSO, but they are
/// logging in using an API Key (client_credentials) then they are allowed to bypass the SSO requirement.
/// </summary>
/// <param name="user">user trying to login</param>
/// <param name="grantType">magic string identifying the grant type requested</param>
/// <returns></returns>
private async Task<bool> IsValidAuthTypeAsync(User user, string grantType)
{
if (grantType == "authorization_code" || grantType == "client_credentials")
@ -367,7 +281,6 @@ public abstract class BaseRequestValidator<T> where T : class
return true;
}
// Check if user belongs to any organization with an active SSO policy
var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
if (anySsoPoliciesApplicableToUser)
@ -379,134 +292,6 @@ public abstract class BaseRequestValidator<T> where T : class
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)
{
// Early escape if db hit not necessary
@ -546,7 +331,7 @@ public abstract class BaseRequestValidator<T> where T : class
}
/// <summary>
/// checks to see if a user is trying to log into a new device
/// checks to see if a user is trying to log into a new device
/// and has reached the maximum number of failed login attempts.
/// </summary>
/// <param name="unknownDevice">boolean</param>

View File

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

View File

@ -8,7 +8,7 @@ using Bit.Core.Services;
using Bit.Core.Settings;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer;
namespace Bit.Identity.IdentityServer.RequestValidators;
public interface IDeviceValidator
{
@ -41,6 +41,12 @@ public class DeviceValidator(
private readonly IMailService _mailService = mailService;
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)
{
var device = GetDeviceFromRequest(request);

View File

@ -1,8 +1,6 @@
using System.Security.Claims;
using Bit.Core;
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.Services;
using Bit.Core.Context;
@ -10,13 +8,12 @@ using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Validation;
using Microsoft.AspNetCore.Identity;
namespace Bit.Identity.IdentityServer;
namespace Bit.Identity.IdentityServer.RequestValidators;
public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwnerPasswordValidationContext>,
IResourceOwnerPasswordValidator
@ -31,11 +28,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
IUserService userService,
IEventService eventService,
IDeviceValidator deviceValidator,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
ITemporaryDuoWebV4SDKService duoWebV4SDKService,
IOrganizationRepository organizationRepository,
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
IOrganizationUserRepository organizationUserRepository,
IApplicationCacheService applicationCacheService,
IMailService mailService,
ILogger<ResourceOwnerPasswordValidator> logger,
ICurrentContext currentContext,
@ -44,14 +38,25 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
IAuthRequestRepository authRequestRepository,
IUserRepository userRepository,
IPolicyService policyService,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
: base(userManager, userService, eventService, deviceValidator,
organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository,
applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService,
tokenDataFactory, featureService, ssoConfigRepository, userDecryptionOptionsBuilder)
: base(
userManager,
userService,
eventService,
deviceValidator,
twoFactorAuthenticationValidator,
organizationUserRepository,
mailService,
logger,
currentContext,
globalSettings,
userRepository,
policyService,
featureService,
ssoConfigRepository,
userDecryptionOptionsBuilder)
{
_userManager = userManager;
_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.Text.Json;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
@ -19,7 +17,7 @@ using Duende.IdentityServer.Validation;
using Fido2NetLib;
using Microsoft.AspNetCore.Identity;
namespace Bit.Identity.IdentityServer;
namespace Bit.Identity.IdentityServer.RequestValidators;
public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidationContext>, IExtensionGrantValidator
{
@ -34,11 +32,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
IUserService userService,
IEventService eventService,
IDeviceValidator deviceValidator,
IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
ITemporaryDuoWebV4SDKService duoWebV4SDKService,
IOrganizationRepository organizationRepository,
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
IOrganizationUserRepository organizationUserRepository,
IApplicationCacheService applicationCacheService,
IMailService mailService,
ILogger<CustomTokenRequestValidator> logger,
ICurrentContext currentContext,
@ -46,16 +41,27 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
ISsoConfigRepository ssoConfigRepository,
IUserRepository userRepository,
IPolicyService policyService,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector,
IFeatureService featureService,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand
)
: base(userManager, userService, eventService, deviceValidator,
organizationDuoWebTokenProvider, duoWebV4SDKService, organizationRepository, organizationUserRepository,
applicationCacheService, mailService, logger, currentContext, globalSettings,
userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, userDecryptionOptionsBuilder)
: base(
userManager,
userService,
eventService,
deviceValidator,
twoFactorAuthenticationValidator,
organizationUserRepository,
mailService,
logger,
currentContext,
globalSettings,
userRepository,
policyService,
featureService,
ssoConfigRepository,
userDecryptionOptionsBuilder)
{
_assertionOptionsDataProtector = assertionOptionsDataProtector;
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
@ -122,12 +128,6 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
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,
Dictionary<string, object> customResponse)
{

View File

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

View File

@ -196,8 +196,7 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
return results.SingleOrDefault();
}
}
public async Task<Tuple<OrganizationUserUserDetails?, ICollection<CollectionAccessSelection>>>
GetDetailsByIdWithCollectionsAsync(Guid id)
public async Task<(OrganizationUserUserDetails? OrganizationUser, ICollection<CollectionAccessSelection> Collections)> GetDetailsByIdWithCollectionsAsync(Guid id)
{
using (var connection = new SqlConnection(ConnectionString))
{
@ -206,9 +205,9 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
new { Id = id },
commandType: CommandType.StoredProcedure);
var user = (await results.ReadAsync<OrganizationUserUserDetails>()).SingleOrDefault();
var organizationUserUserDetails = (await results.ReadAsync<OrganizationUserUserDetails>()).SingleOrDefault();
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);
using (var scope = ServiceScopeFactory.CreateScope())
@ -265,7 +265,7 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
HidePasswords = cu.HidePasswords,
Manage = cu.Manage
}).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)
.IsClustered(false);
builder.Property(c => c.Active)
.ValueGeneratedNever()
.HasDefaultValue(true);
builder.ToTable(nameof(Device));
}
}

View File

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

View File

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

View File

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

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