1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-01 16:12:49 -05:00

Merge branch 'refs/heads/main' into km/pm-10600

This commit is contained in:
Maciej Zieniuk
2024-11-05 15:00:58 +00:00
102 changed files with 18831 additions and 479 deletions

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;
@ -290,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

@ -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,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

@ -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;
@ -545,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))
{
@ -558,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))
{
@ -583,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 =>

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

@ -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

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

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

@ -24,6 +24,7 @@ public record TeamsPlan : Plan
Has2fa = true;
HasApi = true;
UsersGetPremium = true;
HasScim = true;
UpgradeSortOrder = 3;
DisplaySortOrder = 3;

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";

View File

@ -25,7 +25,7 @@
<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.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,7 +58,7 @@
<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="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.10" />
<PackageReference Include="LaunchDarkly.ServerSdk" Version="8.6.0" />
</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

@ -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

@ -0,0 +1,16 @@
using Bit.Core.Entities;
using Bit.Core.Utilities;
public class PasswordHealthReportApplication : ITableObject<Guid>, IRevisable
{
public Guid Id { get; set; }
public Guid OrganizationId { get; set; }
public string Uri { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
public void SetNewId()
{
Id = CoreHelpers.GenerateComb();
}
}

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

@ -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

View File

@ -0,0 +1,10 @@
CREATE PROCEDURE dbo.PasswordHealthReportApplication_Create
@Id UNIQUEIDENTIFIER OUTPUT,
@OrganizationId UNIQUEIDENTIFIER,
@Uri nvarchar(max),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7)
AS
SET NOCOUNT ON;
INSERT INTO dbo.PasswordHealthReportApplication ( Id, OrganizationId, Uri, CreationDate, RevisionDate )
VALUES ( @Id, @OrganizationId, @Uri, @CreationDate, @RevisionDate )

View File

@ -0,0 +1,10 @@
CREATE PROCEDURE dbo.PasswordHealthReportApplication_DeleteById
@Id UNIQUEIDENTIFIER
AS
SET NOCOUNT ON;
IF @Id IS NULL
THROW 50000, 'Id cannot be null', 1;
DELETE FROM [dbo].[PasswordHealthReportApplication]
WHERE [Id] = @Id

View File

@ -0,0 +1,16 @@
CREATE PROCEDURE dbo.PasswordHealthReportApplication_ReadById
@Id UNIQUEIDENTIFIER
AS
SET NOCOUNT ON;
IF @Id IS NULL
THROW 50000, 'Id cannot be null', 1;
SELECT
Id,
OrganizationId,
Uri,
CreationDate,
RevisionDate
FROM [dbo].[PasswordHealthReportApplicationView]
WHERE Id = @Id;

View File

@ -0,0 +1,16 @@
CREATE PROCEDURE dbo.PasswordHealthReportApplication_ReadByOrganizationId
@OrganizationId UNIQUEIDENTIFIER
AS
SET NOCOUNT ON;
IF @OrganizationId IS NULL
THROW 50000, 'OrganizationId cannot be null', 1;
SELECT
Id,
OrganizationId,
Uri,
CreationDate,
RevisionDate
FROM [dbo].[PasswordHealthReportApplicationView]
WHERE OrganizationId = @OrganizationId;

View File

@ -0,0 +1,13 @@
CREATE PROC dbo.PasswordHealthReportApplication_Update
@Id UNIQUEIDENTIFIER OUTPUT,
@OrganizationId UNIQUEIDENTIFIER,
@Uri nvarchar(max),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7)
AS
SET NOCOUNT ON;
UPDATE dbo.PasswordHealthReportApplication
SET OrganizationId = @OrganizationId,
Uri = @Uri,
RevisionDate = @RevisionDate
WHERE Id = @Id

View File

@ -1,25 +1,24 @@
CREATE TABLE [dbo].[Device] (
[Id] UNIQUEIDENTIFIER NOT NULL,
[UserId] UNIQUEIDENTIFIER NOT NULL,
[Name] NVARCHAR (50) NOT NULL,
[Type] SMALLINT NOT NULL,
[Identifier] NVARCHAR (50) NOT NULL,
[PushToken] NVARCHAR (255) NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
[RevisionDate] DATETIME2 (7) NOT NULL,
[EncryptedUserKey] VARCHAR (MAX) NULL,
[EncryptedPublicKey] VARCHAR (MAX) NULL,
[EncryptedPrivateKey] VARCHAR (MAX) NULL,
[Id] UNIQUEIDENTIFIER NOT NULL,
[UserId] UNIQUEIDENTIFIER NOT NULL,
[Name] NVARCHAR (50) NOT NULL,
[Type] SMALLINT NOT NULL,
[Identifier] NVARCHAR (50) NOT NULL,
[PushToken] NVARCHAR (255) NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
[RevisionDate] DATETIME2 (7) NOT NULL,
[EncryptedUserKey] VARCHAR (MAX) NULL,
[EncryptedPublicKey] VARCHAR (MAX) NULL,
[EncryptedPrivateKey] VARCHAR (MAX) NULL,
[Active] BIT NOT NULL CONSTRAINT [DF_Device_Active] DEFAULT (1),
CONSTRAINT [PK_Device] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_Device_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id])
);
GO
CREATE UNIQUE NONCLUSTERED INDEX [UX_Device_UserId_Identifier]
ON [dbo].[Device]([UserId] ASC, [Identifier] ASC);
GO
CREATE NONCLUSTERED INDEX [IX_Device_Identifier]
ON [dbo].[Device]([Identifier] ASC);

View File

@ -0,0 +1,15 @@
CREATE TABLE [dbo].[PasswordHealthReportApplication]
(
Id UNIQUEIDENTIFIER NOT NULL,
OrganizationId UNIQUEIDENTIFIER NOT NULL,
Uri nvarchar(max),
CreationDate DATETIME2(7) NOT NULL,
RevisionDate DATETIME2(7) NOT NULL,
CONSTRAINT [PK_PasswordHealthReportApplication] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_PasswordHealthReportApplication_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
);
GO
CREATE NONCLUSTERED INDEX [IX_PasswordHealthReportApplication_OrganizationId]
ON [dbo].[PasswordHealthReportApplication] (OrganizationId);
GO

View File

@ -0,0 +1,2 @@
CREATE VIEW [dbo].[PasswordHealthReportApplicationView] AS
SELECT * FROM [dbo].[PasswordHealthReportApplication]