mirror of
https://github.com/bitwarden/server
synced 2026-02-25 08:53:21 +00:00
Merge branch 'main' into auth/pm-29584/create-email-for-emergency-access-removal
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Admin</UserSecretsId>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Admin' " />
|
||||
|
||||
@@ -65,6 +65,7 @@ public class Startup
|
||||
default:
|
||||
break;
|
||||
}
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -665,11 +665,6 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
[Authorize<ManageUsersRequirement>]
|
||||
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2))
|
||||
{
|
||||
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
|
||||
}
|
||||
|
||||
var currentUserId = _userService.GetProperUserId(User);
|
||||
if (currentUserId == null)
|
||||
{
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||
<ANCMPreConfiguredForIIS>true</ANCMPreConfiguredForIIS>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
|
||||
@@ -36,7 +36,7 @@ public class EmergencyAccessUpdateRequestModel
|
||||
existingEmergencyAccess.KeyEncrypted = KeyEncrypted;
|
||||
}
|
||||
existingEmergencyAccess.Type = Type;
|
||||
existingEmergencyAccess.WaitTimeDays = WaitTimeDays;
|
||||
existingEmergencyAccess.WaitTimeDays = (short)WaitTimeDays;
|
||||
return existingEmergencyAccess;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public class AccountsController(
|
||||
IFeatureService featureService,
|
||||
ILicensingService licensingService) : Controller
|
||||
{
|
||||
// TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work.
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
[HttpGet("subscription")]
|
||||
public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
|
||||
[FromServices] GlobalSettings globalSettings,
|
||||
@@ -61,7 +61,7 @@ public class AccountsController(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
[HttpPost("storage")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model)
|
||||
@@ -118,7 +118,7 @@ public class AccountsController(
|
||||
user.IsExpired());
|
||||
}
|
||||
|
||||
// TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
[HttpPost("reinstate-premium")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task PostReinstateAsync()
|
||||
@@ -131,10 +131,4 @@ public class AccountsController(
|
||||
|
||||
await userService.ReinstatePremiumAsync(user);
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
|
||||
{
|
||||
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
|
||||
return organizationsClaimingUser.Select(o => o.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ using Bit.Core.Billing.Licenses.Queries;
|
||||
using Bit.Core.Billing.Payment.Commands;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Subscriptions.Commands;
|
||||
using Bit.Core.Billing.Subscriptions.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -21,9 +23,11 @@ namespace Bit.Api.Billing.Controllers.VNext;
|
||||
public class AccountBillingVNextController(
|
||||
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
|
||||
ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand,
|
||||
IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery,
|
||||
IGetCreditQuery getCreditQuery,
|
||||
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||
IGetUserLicenseQuery getUserLicenseQuery,
|
||||
IReinstateSubscriptionCommand reinstateSubscriptionCommand,
|
||||
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
|
||||
IUpdatePremiumStorageCommand updatePremiumStorageCommand,
|
||||
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
|
||||
@@ -91,10 +95,30 @@ public class AccountBillingVNextController(
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpPut("storage")]
|
||||
[HttpGet("subscription")]
|
||||
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> UpdateStorageAsync(
|
||||
public async Task<IResult> GetSubscriptionAsync(
|
||||
[BindNever] User user)
|
||||
{
|
||||
var subscription = await getBitwardenSubscriptionQuery.Run(user);
|
||||
return TypedResults.Ok(subscription);
|
||||
}
|
||||
|
||||
[HttpPost("subscription/reinstate")]
|
||||
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> ReinstateSubscriptionAsync(
|
||||
[BindNever] User user)
|
||||
{
|
||||
var result = await reinstateSubscriptionCommand.Run(user);
|
||||
return Handle(result);
|
||||
}
|
||||
|
||||
[HttpPut("subscription/storage")]
|
||||
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> UpdateSubscriptionStorageAsync(
|
||||
[BindNever] User user,
|
||||
[FromBody] StorageUpdateRequest request)
|
||||
{
|
||||
|
||||
@@ -13,7 +13,6 @@ public class StorageUpdateRequest : IValidatableObject
|
||||
/// Must be between 0 and the maximum allowed (minus base storage).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Range(0, 99)]
|
||||
public short AdditionalStorageGb { get; set; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
@@ -22,14 +21,14 @@ public class StorageUpdateRequest : IValidatableObject
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Additional storage cannot be negative.",
|
||||
new[] { nameof(AdditionalStorageGb) });
|
||||
[nameof(AdditionalStorageGb)]);
|
||||
}
|
||||
|
||||
if (AdditionalStorageGb > 99)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Maximum additional storage is 99 GB.",
|
||||
new[] { nameof(AdditionalStorageGb) });
|
||||
[nameof(AdditionalStorageGb)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.Models.Response;
|
||||
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
public class SubscriptionResponseModel : ResponseModel
|
||||
{
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Billing</UserSecretsId>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Label="Server SDK settings">
|
||||
<!-- These features will be gradually turned on -->
|
||||
<BitIncludeFeatures>false</BitIncludeFeatures>
|
||||
<BitIncludeTelemetry>false</BitIncludeTelemetry>
|
||||
<BitIncludeAuthentication>false</BitIncludeAuthentication>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Services;
|
||||
using Event = Stripe.Event;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class InvoiceCreatedHandler(
|
||||
IBraintreeService braintreeService,
|
||||
ILogger<InvoiceCreatedHandler> logger,
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeEventUtilityService stripeEventUtilityService,
|
||||
IProviderEventService providerEventService)
|
||||
: IInvoiceCreatedHandler
|
||||
{
|
||||
@@ -29,9 +30,9 @@ public class InvoiceCreatedHandler(
|
||||
{
|
||||
try
|
||||
{
|
||||
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]);
|
||||
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer", "parent.subscription_details.subscription"]);
|
||||
|
||||
var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false;
|
||||
var usingPayPal = invoice.Customer.Metadata.ContainsKey("btCustomerId");
|
||||
|
||||
if (usingPayPal && invoice is
|
||||
{
|
||||
@@ -39,13 +40,12 @@ public class InvoiceCreatedHandler(
|
||||
Status: not StripeConstants.InvoiceStatus.Paid,
|
||||
CollectionMethod: "charge_automatically",
|
||||
BillingReason:
|
||||
"subscription_create" or
|
||||
"subscription_cycle" or
|
||||
"automatic_pending_invoice_item_invoice",
|
||||
Parent.SubscriptionDetails: not null
|
||||
Parent.SubscriptionDetails.Subscription: not null
|
||||
})
|
||||
{
|
||||
await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
|
||||
await braintreeService.PayInvoice(invoice.Parent.SubscriptionDetails.Subscription, invoice);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
|
||||
@@ -48,6 +48,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// PayPal IPN Client
|
||||
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();
|
||||
|
||||
@@ -128,6 +128,7 @@ public class SelfHostedOrganizationDetails : Organization
|
||||
UseApi = UseApi,
|
||||
UseResetPassword = UseResetPassword,
|
||||
UseSecretsManager = UseSecretsManager,
|
||||
UsePasswordManager = UsePasswordManager,
|
||||
SelfHost = SelfHost,
|
||||
UsersGetPremium = UsersGetPremium,
|
||||
UseCustomPermissions = UseCustomPermissions,
|
||||
@@ -156,6 +157,8 @@ public class SelfHostedOrganizationDetails : Organization
|
||||
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
|
||||
UseDisableSmAdsForUsers = UseDisableSmAdsForUsers,
|
||||
UsePhishingBlocker = UsePhishingBlocker,
|
||||
UseOrganizationDomains = UseOrganizationDomains,
|
||||
UseAutomaticUserConfirmation = UseAutomaticUserConfirmation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,4 @@ public interface IRevokeOrganizationUserCommand
|
||||
{
|
||||
Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId);
|
||||
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
|
||||
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
|
||||
}
|
||||
|
||||
@@ -68,68 +68,4 @@ public class RevokeOrganizationUserCommand(
|
||||
await organizationUserRepository.RevokeAsync(organizationUser.Id);
|
||||
organizationUser.Status = OrganizationUserStatusType.Revoked;
|
||||
}
|
||||
|
||||
public async Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId)
|
||||
{
|
||||
var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds);
|
||||
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
|
||||
.ToList();
|
||||
|
||||
if (!filteredUsers.Any())
|
||||
{
|
||||
throw new BadRequestException("Users invalid.");
|
||||
}
|
||||
|
||||
if (!await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUserIds))
|
||||
{
|
||||
throw new BadRequestException("Organization must have at least one confirmed owner.");
|
||||
}
|
||||
|
||||
var deletingUserIsOwner = false;
|
||||
if (revokingUserId.HasValue)
|
||||
{
|
||||
deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId);
|
||||
}
|
||||
|
||||
var result = new List<Tuple<OrganizationUser, string>>();
|
||||
|
||||
foreach (var organizationUser in filteredUsers)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (organizationUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
throw new BadRequestException("Already revoked.");
|
||||
}
|
||||
|
||||
if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId)
|
||||
{
|
||||
throw new BadRequestException("You cannot revoke yourself.");
|
||||
}
|
||||
|
||||
if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue &&
|
||||
!deletingUserIsOwner)
|
||||
{
|
||||
throw new BadRequestException("Only owners can revoke other owners.");
|
||||
}
|
||||
|
||||
await organizationUserRepository.RevokeAsync(organizationUser.Id);
|
||||
organizationUser.Status = OrganizationUserStatusType.Revoked;
|
||||
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
|
||||
if (organizationUser.UserId.HasValue)
|
||||
{
|
||||
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
|
||||
}
|
||||
|
||||
result.Add(Tuple.Create(organizationUser, ""));
|
||||
}
|
||||
catch (BadRequestException e)
|
||||
{
|
||||
result.Add(Tuple.Create(organizationUser, e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public class EmergencyAccess : ITableObject<Guid>
|
||||
public string KeyEncrypted { get; set; }
|
||||
public EmergencyAccessType Type { get; set; }
|
||||
public EmergencyAccessStatusType Status { get; set; }
|
||||
public int WaitTimeDays { get; set; }
|
||||
public short WaitTimeDays { get; set; }
|
||||
public DateTime? RecoveryInitiatedDate { get; set; }
|
||||
public DateTime? LastNotificationDate { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -78,7 +78,7 @@ public class EmergencyAccessService : IEmergencyAccessService
|
||||
Email = emergencyContactEmail.ToLowerInvariant(),
|
||||
Status = EmergencyAccessStatusType.Invited,
|
||||
Type = accessType,
|
||||
WaitTimeDays = waitTime,
|
||||
WaitTimeDays = (short)waitTime,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
@@ -42,6 +42,7 @@ public static class StripeConstants
|
||||
public static class ErrorCodes
|
||||
{
|
||||
public const string CustomerTaxLocationInvalid = "customer_tax_location_invalid";
|
||||
public const string InvoiceUpcomingNone = "invoice_upcoming_none";
|
||||
public const string PaymentMethodMicroDepositVerificationAttemptsExceeded = "payment_method_microdeposit_verification_attempts_exceeded";
|
||||
public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch";
|
||||
public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout";
|
||||
@@ -65,8 +66,10 @@ public static class StripeConstants
|
||||
public static class MetadataKeys
|
||||
{
|
||||
public const string BraintreeCustomerId = "btCustomerId";
|
||||
public const string BraintreeTransactionId = "btTransactionId";
|
||||
public const string InvoiceApproved = "invoice_approved";
|
||||
public const string OrganizationId = "organizationId";
|
||||
public const string PayPalTransactionId = "btPayPalTransactionId";
|
||||
public const string PreviousAdditionalStorage = "previous_additional_storage";
|
||||
public const string PreviousPeriodEndDate = "previous_period_end_date";
|
||||
public const string PreviousPremiumPriceId = "previous_premium_price_id";
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
namespace Bit.Core.Billing.Enums;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace Bit.Core.Billing.Enums;
|
||||
|
||||
public enum PlanCadenceType
|
||||
{
|
||||
[EnumMember(Value = "annually")]
|
||||
Annually,
|
||||
[EnumMember(Value = "monthly")]
|
||||
Monthly
|
||||
}
|
||||
|
||||
12
src/Core/Billing/Extensions/DiscountExtensions.cs
Normal file
12
src/Core/Billing/Extensions/DiscountExtensions.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
|
||||
public static class DiscountExtensions
|
||||
{
|
||||
public static bool AppliesTo(this Discount discount, SubscriptionItem subscriptionItem)
|
||||
=> discount.Coupon.AppliesTo.Products.Contains(subscriptionItem.Price.Product.Id);
|
||||
|
||||
public static bool IsValid(this Discount? discount)
|
||||
=> discount?.Coupon?.Valid ?? false;
|
||||
}
|
||||
@@ -12,8 +12,11 @@ using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Services.Implementations;
|
||||
using Bit.Core.Billing.Subscriptions.Commands;
|
||||
using Bit.Core.Billing.Subscriptions.Queries;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Billing.Tax.Services.Implementations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Services.Implementations;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
|
||||
@@ -39,6 +42,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>();
|
||||
services.AddTransient<IRestartSubscriptionCommand, RestartSubscriptionCommand>();
|
||||
services.AddTransient<IPreviewOrganizationTaxCommand, PreviewOrganizationTaxCommand>();
|
||||
services.AddTransient<IGetBitwardenSubscriptionQuery, GetBitwardenSubscriptionQuery>();
|
||||
services.AddTransient<IReinstateSubscriptionCommand, ReinstateSubscriptionCommand>();
|
||||
services.AddTransient<IBraintreeService, BraintreeService>();
|
||||
}
|
||||
|
||||
private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Licenses.Extensions;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Services;
|
||||
@@ -46,6 +48,57 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
|
||||
}
|
||||
|
||||
var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license);
|
||||
|
||||
// If the license has a Token (claims-based), extract all properties from claims BEFORE validation
|
||||
// This ensures that CanUseLicense validation has access to the correct values from claims
|
||||
// Otherwise, fall back to using the properties already on the license object (backward compatibility)
|
||||
if (claimsPrincipal != null)
|
||||
{
|
||||
license.Name = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Name);
|
||||
license.BillingEmail = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BillingEmail);
|
||||
license.BusinessName = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BusinessName);
|
||||
license.PlanType = claimsPrincipal.GetValue<PlanType>(OrganizationLicenseConstants.PlanType);
|
||||
license.Seats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.Seats);
|
||||
license.MaxCollections = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxCollections);
|
||||
license.UsePolicies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePolicies);
|
||||
license.UseSso = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSso);
|
||||
license.UseKeyConnector = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseKeyConnector);
|
||||
license.UseScim = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseScim);
|
||||
license.UseGroups = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseGroups);
|
||||
license.UseDirectory = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDirectory);
|
||||
license.UseEvents = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseEvents);
|
||||
license.UseTotp = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseTotp);
|
||||
license.Use2fa = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Use2fa);
|
||||
license.UseApi = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseApi);
|
||||
license.UseResetPassword = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseResetPassword);
|
||||
license.Plan = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Plan);
|
||||
license.SelfHost = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.SelfHost);
|
||||
license.UsersGetPremium = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsersGetPremium);
|
||||
license.UseCustomPermissions = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseCustomPermissions);
|
||||
license.Enabled = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Enabled);
|
||||
license.Expires = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Expires);
|
||||
license.LicenseKey = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.LicenseKey);
|
||||
license.UsePasswordManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePasswordManager);
|
||||
license.UseSecretsManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSecretsManager);
|
||||
license.SmSeats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmSeats);
|
||||
license.SmServiceAccounts = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmServiceAccounts);
|
||||
license.UseRiskInsights = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseRiskInsights);
|
||||
license.UseOrganizationDomains = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseOrganizationDomains);
|
||||
license.UseAdminSponsoredFamilies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAdminSponsoredFamilies);
|
||||
license.UseAutomaticUserConfirmation = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation);
|
||||
license.UseDisableSmAdsForUsers = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDisableSmAdsForUsers);
|
||||
license.UsePhishingBlocker = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePhishingBlocker);
|
||||
license.MaxStorageGb = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxStorageGb);
|
||||
license.InstallationId = claimsPrincipal.GetValue<Guid>(OrganizationLicenseConstants.InstallationId);
|
||||
license.LicenseType = claimsPrincipal.GetValue<LicenseType>(OrganizationLicenseConstants.LicenseType);
|
||||
license.Issued = claimsPrincipal.GetValue<DateTime>(OrganizationLicenseConstants.Issued);
|
||||
license.Refresh = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Refresh);
|
||||
license.ExpirationWithoutGracePeriod = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.ExpirationWithoutGracePeriod);
|
||||
license.Trial = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Trial);
|
||||
license.LimitCollectionCreationDeletion = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.LimitCollectionCreationDeletion);
|
||||
license.AllowAdminAccessToAllCollectionItems = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems);
|
||||
}
|
||||
|
||||
var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) &&
|
||||
selfHostedOrganization.CanUseLicense(license, out exception);
|
||||
|
||||
@@ -54,12 +107,6 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
|
||||
throw new BadRequestException(exception);
|
||||
}
|
||||
|
||||
var useAutomaticUserConfirmation = claimsPrincipal?
|
||||
.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false;
|
||||
|
||||
selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
|
||||
license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
|
||||
|
||||
await WriteLicenseFileAsync(selfHostedOrganization, license);
|
||||
await UpdateOrganizationAsync(selfHostedOrganization, license);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Platform.Push;
|
||||
@@ -49,6 +50,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
|
||||
|
||||
public class CreatePremiumCloudHostedSubscriptionCommand(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IBraintreeService braintreeService,
|
||||
IGlobalSettings globalSettings,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
@@ -300,6 +302,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
||||
ValidateLocation = ValidateTaxLocationTiming.Immediately
|
||||
}
|
||||
};
|
||||
|
||||
return await stripeAdapter.UpdateCustomerAsync(customer.Id, options);
|
||||
}
|
||||
|
||||
@@ -351,14 +354,18 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
||||
|
||||
var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions);
|
||||
|
||||
if (usingPayPal)
|
||||
if (!usingPayPal)
|
||||
{
|
||||
await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
|
||||
{
|
||||
AutoAdvance = false
|
||||
});
|
||||
return subscription;
|
||||
}
|
||||
|
||||
var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
|
||||
{
|
||||
AutoAdvance = false
|
||||
});
|
||||
|
||||
await braintreeService.PayInvoice(new UserId(userId), invoice);
|
||||
|
||||
return subscription;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
@@ -10,6 +11,8 @@ using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Premium.Commands;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the storage allocation for a premium user's subscription.
|
||||
/// Handles both increases and decreases in storage in an idempotent manner.
|
||||
@@ -34,14 +37,14 @@ public class UpdatePremiumStorageCommand(
|
||||
{
|
||||
public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb) => HandleAsync<None>(async () =>
|
||||
{
|
||||
if (!user.Premium)
|
||||
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
|
||||
{
|
||||
return new BadRequest("User does not have a premium subscription.");
|
||||
}
|
||||
|
||||
if (!user.MaxStorageGb.HasValue)
|
||||
{
|
||||
return new BadRequest("No access to storage.");
|
||||
return new BadRequest("User has no access to storage.");
|
||||
}
|
||||
|
||||
// Fetch all premium plans and the user's subscription to find which plan they're on
|
||||
@@ -54,7 +57,7 @@ public class UpdatePremiumStorageCommand(
|
||||
|
||||
if (passwordManagerItem == null)
|
||||
{
|
||||
return new BadRequest("Premium subscription item not found.");
|
||||
return new Conflict("Premium subscription does not have a Password Manager line item.");
|
||||
}
|
||||
|
||||
var premiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
|
||||
@@ -66,20 +69,20 @@ public class UpdatePremiumStorageCommand(
|
||||
return new BadRequest("Additional storage cannot be negative.");
|
||||
}
|
||||
|
||||
var newTotalStorageGb = (short)(baseStorageGb + additionalStorageGb);
|
||||
var maxStorageGb = (short)(baseStorageGb + additionalStorageGb);
|
||||
|
||||
if (newTotalStorageGb > 100)
|
||||
if (maxStorageGb > 100)
|
||||
{
|
||||
return new BadRequest("Maximum storage is 100 GB.");
|
||||
}
|
||||
|
||||
// Idempotency check: if user already has the requested storage, return success
|
||||
if (user.MaxStorageGb == newTotalStorageGb)
|
||||
if (user.MaxStorageGb == maxStorageGb)
|
||||
{
|
||||
return new None();
|
||||
}
|
||||
|
||||
var remainingStorage = user.StorageBytesRemaining(newTotalStorageGb);
|
||||
var remainingStorage = user.StorageBytesRemaining(maxStorageGb);
|
||||
if (remainingStorage < 0)
|
||||
{
|
||||
return new BadRequest(
|
||||
@@ -124,21 +127,18 @@ public class UpdatePremiumStorageCommand(
|
||||
});
|
||||
}
|
||||
|
||||
// Update subscription with prorations
|
||||
// Storage is billed annually, so we create prorations and invoice immediately
|
||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = subscriptionItemOptions,
|
||||
ProrationBehavior = Core.Constants.CreateProrations
|
||||
ProrationBehavior = ProrationBehavior.AlwaysInvoice
|
||||
};
|
||||
|
||||
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, subscriptionUpdateOptions);
|
||||
|
||||
// Update the user's max storage
|
||||
user.MaxStorageGb = newTotalStorageGb;
|
||||
user.MaxStorageGb = maxStorageGb;
|
||||
await userService.SaveUserAsync(user);
|
||||
|
||||
// No payment intent needed - the subscription update will automatically create and finalize the invoice
|
||||
return new None();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OneOf.Types;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Commands;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public interface IReinstateSubscriptionCommand
|
||||
{
|
||||
Task<BillingCommandResult<None>> Run(ISubscriber subscriber);
|
||||
}
|
||||
|
||||
public class ReinstateSubscriptionCommand(
|
||||
ILogger<ReinstateSubscriptionCommand> logger,
|
||||
IStripeAdapter stripeAdapter) : BaseBillingCommand<ReinstateSubscriptionCommand>(logger), IReinstateSubscriptionCommand
|
||||
{
|
||||
public Task<BillingCommandResult<None>> Run(ISubscriber subscriber) => HandleAsync<None>(async () =>
|
||||
{
|
||||
var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId);
|
||||
|
||||
if (subscription is not
|
||||
{
|
||||
Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active,
|
||||
CancelAt: not null
|
||||
})
|
||||
{
|
||||
return new BadRequest("Subscription is not pending cancellation.");
|
||||
}
|
||||
|
||||
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions
|
||||
{
|
||||
CancelAtPeriodEnd = false
|
||||
});
|
||||
|
||||
return new None();
|
||||
});
|
||||
}
|
||||
61
src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs
Normal file
61
src/Core/Billing/Subscriptions/Models/BitwardenDiscount.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Utilities;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The type of discounts Bitwarden supports.
|
||||
/// </summary>
|
||||
public enum BitwardenDiscountType
|
||||
{
|
||||
[EnumMember(Value = "amount-off")]
|
||||
AmountOff,
|
||||
|
||||
[EnumMember(Value = "percent-off")]
|
||||
PercentOff
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A record representing a discount applied to a Bitwarden subscription.
|
||||
/// </summary>
|
||||
public record BitwardenDiscount
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of the discount.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<BitwardenDiscountType>))]
|
||||
public required BitwardenDiscountType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The value of the discount.
|
||||
/// </summary>
|
||||
public required decimal Value { get; init; }
|
||||
|
||||
public static implicit operator BitwardenDiscount(Discount? discount)
|
||||
{
|
||||
if (discount is not
|
||||
{
|
||||
Coupon.Valid: true
|
||||
})
|
||||
{
|
||||
return null!;
|
||||
}
|
||||
|
||||
return discount.Coupon switch
|
||||
{
|
||||
{ AmountOff: > 0 } => new BitwardenDiscount
|
||||
{
|
||||
Type = BitwardenDiscountType.AmountOff,
|
||||
Value = discount.Coupon.AmountOff.Value
|
||||
},
|
||||
{ PercentOff: > 0 } => new BitwardenDiscount
|
||||
{
|
||||
Type = BitwardenDiscountType.PercentOff,
|
||||
Value = discount.Coupon.PercentOff.Value
|
||||
},
|
||||
_ => null!
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
public record BitwardenSubscription
|
||||
{
|
||||
/// <summary>
|
||||
/// The status of the subscription.
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subscription's cart, including line items, any discounts, and estimated tax.
|
||||
/// </summary>
|
||||
public required Cart Cart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The amount of storage available and used for the subscription.
|
||||
/// <remarks>Allowed Subscribers: User, Organization</remarks>
|
||||
/// </summary>
|
||||
public Storage? Storage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If the subscription is pending cancellation, the date at which the
|
||||
/// subscription will be canceled.
|
||||
/// <remarks>Allowed Statuses: 'trialing', 'active'</remarks>
|
||||
/// </summary>
|
||||
public DateTime? CancelAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The date the subscription was canceled.
|
||||
/// <remarks>Allowed Statuses: 'canceled'</remarks>
|
||||
/// </summary>
|
||||
public DateTime? Canceled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The date of the next charge for the subscription.
|
||||
/// <remarks>Allowed Statuses: 'trialing', 'active'</remarks>
|
||||
/// </summary>
|
||||
public DateTime? NextCharge { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The date the subscription will be or was suspended due to lack of payment.
|
||||
/// <remarks>Allowed Statuses: 'incomplete', 'incomplete_expired', 'past_due', 'unpaid'</remarks>
|
||||
/// </summary>
|
||||
public DateTime? Suspension { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of days after the subscription goes 'past_due' the subscriber has to resolve their
|
||||
/// open invoices before the subscription is suspended.
|
||||
/// <remarks>Allowed Statuses: 'past_due'</remarks>
|
||||
/// </summary>
|
||||
public int? GracePeriod { get; init; }
|
||||
}
|
||||
83
src/Core/Billing/Subscriptions/Models/Cart.cs
Normal file
83
src/Core/Billing/Subscriptions/Models/Cart.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
public record CartItem
|
||||
{
|
||||
/// <summary>
|
||||
/// The client-side translation key for the name of the cart item.
|
||||
/// </summary>
|
||||
public required string TranslationKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The quantity of the cart item.
|
||||
/// </summary>
|
||||
public required long Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The unit-cost of the cart item.
|
||||
/// </summary>
|
||||
public required decimal Cost { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// An optional discount applied specifically to this cart item.
|
||||
/// </summary>
|
||||
public BitwardenDiscount? Discount { get; init; }
|
||||
}
|
||||
|
||||
public record PasswordManagerCartItems
|
||||
{
|
||||
/// <summary>
|
||||
/// The Password Manager seats in the cart.
|
||||
/// </summary>
|
||||
public required CartItem Seats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The additional storage in the cart.
|
||||
/// </summary>
|
||||
public CartItem? AdditionalStorage { get; init; }
|
||||
}
|
||||
|
||||
public record SecretsManagerCartItems
|
||||
{
|
||||
/// <summary>
|
||||
/// The Secrets Manager seats in the cart.
|
||||
/// </summary>
|
||||
public required CartItem Seats { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The additional service accounts in the cart.
|
||||
/// </summary>
|
||||
public CartItem? AdditionalServiceAccounts { get; init; }
|
||||
}
|
||||
|
||||
public record Cart
|
||||
{
|
||||
/// <summary>
|
||||
/// The Password Manager items in the cart.
|
||||
/// </summary>
|
||||
public required PasswordManagerCartItems PasswordManager { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The Secrets Manager items in the cart.
|
||||
/// </summary>
|
||||
public SecretsManagerCartItems? SecretsManager { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The cart's billing cadence.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<PlanCadenceType>))]
|
||||
public PlanCadenceType Cadence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// An optional discount applied to the entire cart.
|
||||
/// </summary>
|
||||
public BitwardenDiscount? Discount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The estimated tax for the cart.
|
||||
/// </summary>
|
||||
public required decimal EstimatedTax { get; init; }
|
||||
}
|
||||
52
src/Core/Billing/Subscriptions/Models/Storage.cs
Normal file
52
src/Core/Billing/Subscriptions/Models/Storage.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
using OneOf;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
public record Storage
|
||||
{
|
||||
private const double _bytesPerGibibyte = 1073741824D;
|
||||
|
||||
/// <summary>
|
||||
/// The amount of storage the subscriber has available.
|
||||
/// </summary>
|
||||
public required short Available { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The amount of storage the subscriber has used.
|
||||
/// </summary>
|
||||
public required double Used { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The amount of storage the subscriber has used, formatted as a human-readable string.
|
||||
/// </summary>
|
||||
public required string ReadableUsed { get; init; }
|
||||
|
||||
public static implicit operator Storage(User user) => From(user);
|
||||
public static implicit operator Storage(Organization organization) => From(organization);
|
||||
|
||||
private static Storage From(OneOf<User, Organization> subscriber)
|
||||
{
|
||||
var maxStorageGB = subscriber.Match(
|
||||
user => user.MaxStorageGb,
|
||||
organization => organization.MaxStorageGb);
|
||||
|
||||
if (maxStorageGB == null)
|
||||
{
|
||||
return null!;
|
||||
}
|
||||
|
||||
var storage = subscriber.Match(
|
||||
user => user.Storage,
|
||||
organization => organization.Storage);
|
||||
|
||||
return new Storage
|
||||
{
|
||||
Available = maxStorageGB.Value,
|
||||
Used = Math.Round((storage ?? 0) / _bytesPerGibibyte, 2),
|
||||
ReadableUsed = CoreHelpers.ReadableBytesSize(storage ?? 0)
|
||||
};
|
||||
}
|
||||
}
|
||||
43
src/Core/Billing/Subscriptions/Models/SubscriberId.cs
Normal file
43
src/Core/Billing/Subscriptions/Models/SubscriberId.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Exceptions;
|
||||
using OneOf;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Models;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public record UserId(Guid Value);
|
||||
|
||||
public record OrganizationId(Guid Value);
|
||||
|
||||
public record ProviderId(Guid Value);
|
||||
|
||||
public class SubscriberId : OneOfBase<UserId, OrganizationId, ProviderId>
|
||||
{
|
||||
private SubscriberId(OneOf<UserId, OrganizationId, ProviderId> input) : base(input) { }
|
||||
|
||||
public static implicit operator SubscriberId(UserId value) => new(value);
|
||||
public static implicit operator SubscriberId(OrganizationId value) => new(value);
|
||||
public static implicit operator SubscriberId(ProviderId value) => new(value);
|
||||
|
||||
public static implicit operator SubscriberId(Subscription subscription)
|
||||
{
|
||||
if (subscription.Metadata.TryGetValue(MetadataKeys.UserId, out var userIdValue)
|
||||
&& Guid.TryParse(userIdValue, out var userId))
|
||||
{
|
||||
return new UserId(userId);
|
||||
}
|
||||
|
||||
if (subscription.Metadata.TryGetValue(MetadataKeys.OrganizationId, out var organizationIdValue)
|
||||
&& Guid.TryParse(organizationIdValue, out var organizationId))
|
||||
{
|
||||
return new OrganizationId(organizationId);
|
||||
}
|
||||
|
||||
return subscription.Metadata.TryGetValue(MetadataKeys.ProviderId, out var providerIdValue) &&
|
||||
Guid.TryParse(providerIdValue, out var providerId)
|
||||
? new ProviderId(providerId)
|
||||
: throw new ConflictException("Subscription does not have a valid subscriber ID");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OneOf;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Subscriptions.Queries;
|
||||
|
||||
using static StripeConstants;
|
||||
using static Utilities;
|
||||
|
||||
public interface IGetBitwardenSubscriptionQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves detailed subscription information for a user, including subscription status,
|
||||
/// cart items, discounts, and billing details.
|
||||
/// </summary>
|
||||
/// <param name="user">The user whose subscription information to retrieve.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="BitwardenSubscription"/> containing the subscription details, or null if no
|
||||
/// subscription is found or the subscription status is not recognized.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Currently only supports <see cref="User"/> subscribers. Future versions will support all
|
||||
/// <see cref="ISubscriber"/> types (User and Organization).
|
||||
/// </remarks>
|
||||
Task<BitwardenSubscription> Run(User user);
|
||||
}
|
||||
|
||||
public class GetBitwardenSubscriptionQuery(
|
||||
ILogger<GetBitwardenSubscriptionQuery> logger,
|
||||
IPricingClient pricingClient,
|
||||
IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery
|
||||
{
|
||||
public async Task<BitwardenSubscription> Run(User user)
|
||||
{
|
||||
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions
|
||||
{
|
||||
Expand =
|
||||
[
|
||||
"customer.discount.coupon.applies_to",
|
||||
"discounts.coupon.applies_to",
|
||||
"items.data.price.product",
|
||||
"test_clock"
|
||||
]
|
||||
});
|
||||
|
||||
var cart = await GetPremiumCartAsync(subscription);
|
||||
|
||||
var baseSubscription = new BitwardenSubscription { Status = subscription.Status, Cart = cart, Storage = user };
|
||||
|
||||
switch (subscription.Status)
|
||||
{
|
||||
case SubscriptionStatus.Incomplete:
|
||||
case SubscriptionStatus.IncompleteExpired:
|
||||
return baseSubscription with { Suspension = subscription.Created.AddHours(23), GracePeriod = 1 };
|
||||
|
||||
case SubscriptionStatus.Trialing:
|
||||
case SubscriptionStatus.Active:
|
||||
return baseSubscription with
|
||||
{
|
||||
NextCharge = subscription.GetCurrentPeriodEnd(),
|
||||
CancelAt = subscription.CancelAt
|
||||
};
|
||||
|
||||
case SubscriptionStatus.PastDue:
|
||||
case SubscriptionStatus.Unpaid:
|
||||
var suspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription);
|
||||
if (suspension == null)
|
||||
{
|
||||
return baseSubscription;
|
||||
}
|
||||
return baseSubscription with { Suspension = suspension.SuspensionDate, GracePeriod = suspension.GracePeriod };
|
||||
|
||||
case SubscriptionStatus.Canceled:
|
||||
return baseSubscription with { Canceled = subscription.CanceledAt };
|
||||
|
||||
default:
|
||||
{
|
||||
logger.LogError("Subscription ({SubscriptionID}) has an unmanaged status ({Status})", subscription.Id, subscription.Status);
|
||||
throw new ConflictException("Subscription is in an invalid state. Please contact support for assistance.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Cart> GetPremiumCartAsync(
|
||||
Subscription subscription)
|
||||
{
|
||||
var plans = await pricingClient.ListPremiumPlans();
|
||||
|
||||
var passwordManagerSeatsItem = subscription.Items.FirstOrDefault(item =>
|
||||
plans.Any(plan => plan.Seat.StripePriceId == item.Price.Id));
|
||||
|
||||
if (passwordManagerSeatsItem == null)
|
||||
{
|
||||
throw new ConflictException("Premium subscription does not have a Password Manager line item.");
|
||||
}
|
||||
|
||||
var additionalStorageItem = subscription.Items.FirstOrDefault(item =>
|
||||
plans.Any(plan => plan.Storage.StripePriceId == item.Price.Id));
|
||||
|
||||
var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription);
|
||||
|
||||
var passwordManagerSeats = new CartItem
|
||||
{
|
||||
TranslationKey = "premiumMembership",
|
||||
Quantity = passwordManagerSeatsItem.Quantity,
|
||||
Cost = GetCost(passwordManagerSeatsItem),
|
||||
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(passwordManagerSeatsItem))
|
||||
};
|
||||
|
||||
var additionalStorage = additionalStorageItem != null
|
||||
? new CartItem
|
||||
{
|
||||
TranslationKey = "additionalStorageGB",
|
||||
Quantity = additionalStorageItem.Quantity,
|
||||
Cost = GetCost(additionalStorageItem),
|
||||
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(additionalStorageItem))
|
||||
}
|
||||
: null;
|
||||
|
||||
var estimatedTax = await EstimateTaxAsync(subscription);
|
||||
|
||||
return new Cart
|
||||
{
|
||||
PasswordManager = new PasswordManagerCartItems
|
||||
{
|
||||
Seats = passwordManagerSeats,
|
||||
AdditionalStorage = additionalStorage
|
||||
},
|
||||
Cadence = PlanCadenceType.Annually,
|
||||
Discount = cartLevelDiscount,
|
||||
EstimatedTax = estimatedTax
|
||||
};
|
||||
}
|
||||
|
||||
#region Utilities
|
||||
|
||||
private async Task<decimal> EstimateTaxAsync(Subscription subscription)
|
||||
{
|
||||
try
|
||||
{
|
||||
var invoice = await stripeAdapter.CreateInvoicePreviewAsync(new InvoiceCreatePreviewOptions
|
||||
{
|
||||
Customer = subscription.Customer.Id,
|
||||
Subscription = subscription.Id
|
||||
});
|
||||
|
||||
return GetCost(invoice.TotalTaxes);
|
||||
}
|
||||
catch (StripeException stripeException) when
|
||||
(stripeException.StripeError.Code == ErrorCodes.InvoiceUpcomingNone)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static decimal GetCost(OneOf<SubscriptionItem, List<InvoiceTotalTax>> value) =>
|
||||
value.Match(
|
||||
item => (item.Price.UnitAmountDecimal ?? 0) / 100M,
|
||||
taxes => taxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount) / 100M);
|
||||
|
||||
private static (Discount? CartLevel, List<Discount> ProductLevel) GetStripeDiscounts(
|
||||
Subscription subscription)
|
||||
{
|
||||
var discounts = new List<Discount>();
|
||||
|
||||
if (subscription.Customer.Discount.IsValid())
|
||||
{
|
||||
discounts.Add(subscription.Customer.Discount);
|
||||
}
|
||||
|
||||
discounts.AddRange(subscription.Discounts.Where(discount => discount.IsValid()));
|
||||
|
||||
var cartLevel = new List<Discount>();
|
||||
var productLevel = new List<Discount>();
|
||||
|
||||
foreach (var discount in discounts)
|
||||
{
|
||||
switch (discount)
|
||||
{
|
||||
case { Coupon.AppliesTo.Products: null or { Count: 0 } }:
|
||||
cartLevel.Add(discount);
|
||||
break;
|
||||
case { Coupon.AppliesTo.Products.Count: > 0 }:
|
||||
productLevel.Add(discount);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (cartLevel.FirstOrDefault(), productLevel);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -159,8 +159,6 @@ public static class FeatureFlagKeys
|
||||
public const string Otp6Digits = "pm-18612-otp-6-digits";
|
||||
public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users";
|
||||
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
|
||||
public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword =
|
||||
"pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password";
|
||||
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
|
||||
public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email";
|
||||
public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template";
|
||||
@@ -204,6 +202,7 @@ public static class FeatureFlagKeys
|
||||
public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit";
|
||||
public const string DataRecoveryTool = "pm-28813-data-recovery-tool";
|
||||
public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration";
|
||||
public const string SdkKeyRotation = "pm-30144-sdk-key-rotation";
|
||||
public const string EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration";
|
||||
|
||||
/* Mobile Team */
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<PropertyGroup>
|
||||
<GenerateUserSecretsAttribute>false</GenerateUserSecretsAttribute>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
|
||||
60
src/Core/Entities/PlayItem.cs
Normal file
60
src/Core/Entities/PlayItem.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// PlayItem is a join table tracking entities created during automated testing.
|
||||
/// A `PlayId` is supplied by the clients in the `x-play-id` header to inform the server
|
||||
/// that any data created should be associated with the play, and therefore cleaned up with it.
|
||||
/// </summary>
|
||||
public class PlayItem : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
[MaxLength(256)]
|
||||
public required string PlayId { get; init; }
|
||||
public Guid? UserId { get; init; }
|
||||
public Guid? OrganizationId { get; init; }
|
||||
public DateTime CreationDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Generates and sets a new COMB GUID for the Id property.
|
||||
/// </summary>
|
||||
public void SetNewId()
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PlayItem record associated with a User.
|
||||
/// </summary>
|
||||
/// <param name="user">The user entity created during the play.</param>
|
||||
/// <param name="playId">The play identifier from the x-play-id header.</param>
|
||||
/// <returns>A new PlayItem instance tracking the user.</returns>
|
||||
public static PlayItem Create(User user, string playId)
|
||||
{
|
||||
return new PlayItem
|
||||
{
|
||||
PlayId = playId,
|
||||
UserId = user.Id,
|
||||
CreationDate = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PlayItem record associated with an Organization.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization entity created during the play.</param>
|
||||
/// <param name="playId">The play identifier from the x-play-id header.</param>
|
||||
/// <returns>A new PlayItem instance tracking the organization.</returns>
|
||||
public static PlayItem Create(Organization organization, string playId)
|
||||
{
|
||||
return new PlayItem
|
||||
{
|
||||
PlayId = playId,
|
||||
OrganizationId = organization.Id,
|
||||
CreationDate = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/spot-enterprise.png"
|
||||
title="You can now share passwords with members of {{OrganizationName}}!"
|
||||
button-text="Log in"
|
||||
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
|
||||
button-text="<b>Log in</b>"
|
||||
button-url="{{WebVaultUrl}}"
|
||||
/>
|
||||
</mj-wrapper>
|
||||
@@ -33,7 +33,7 @@
|
||||
icon-alt="Group Users Icon"
|
||||
text="You can easily access and share passwords with your team."
|
||||
foot-url-text="Share passwords in Bitwarden"
|
||||
foot-url="https://bitwarden.com/help/share-to-a-collection/"
|
||||
foot-url="https://bitwarden.com/help/sharing"
|
||||
/>
|
||||
<mj-section background-color="#fff" padding="0 20px 20px 20px">
|
||||
</mj-section>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
|
||||
<mj-bw-hero
|
||||
img-src="https://assets.bitwarden.com/email/v1/spot-family-homes.png"
|
||||
title="You can now share passwords with members of {{OrganizationName}}!"
|
||||
button-text="Log in"
|
||||
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
|
||||
button-text="<b>Log in</b>"
|
||||
button-url="{{WebVaultUrl}}"
|
||||
/>
|
||||
</mj-wrapper>
|
||||
@@ -33,7 +33,7 @@
|
||||
icon-alt="Group Users Icon"
|
||||
text="You can easily share passwords with friends, family, or coworkers."
|
||||
foot-url-text="Share passwords in Bitwarden"
|
||||
foot-url="https://bitwarden.com/help/share-to-a-collection/"
|
||||
foot-url="https://bitwarden.com/help/sharing"
|
||||
/>
|
||||
<mj-section background-color="#fff" padding="0 20px 20px 20px">
|
||||
</mj-section>
|
||||
|
||||
11
src/Core/Repositories/IPlayItemRepository.cs
Normal file
11
src/Core/Repositories/IPlayItemRepository.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Bit.Core.Entities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Repositories;
|
||||
|
||||
public interface IPlayItemRepository : IRepository<PlayItem, Guid>
|
||||
{
|
||||
Task<ICollection<PlayItem>> GetByPlayIdAsync(string playId);
|
||||
Task DeleteByPlayIdAsync(string playId);
|
||||
}
|
||||
11
src/Core/Services/IBraintreeService.cs
Normal file
11
src/Core/Services/IBraintreeService.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public interface IBraintreeService
|
||||
{
|
||||
Task PayInvoice(
|
||||
SubscriberId subscriberId,
|
||||
Invoice invoice);
|
||||
}
|
||||
107
src/Core/Services/Implementations/BraintreeService.cs
Normal file
107
src/Core/Services/Implementations/BraintreeService.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Settings;
|
||||
using Braintree;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Services.Implementations;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public class BraintreeService(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<BraintreeService> logger,
|
||||
IMailService mailService,
|
||||
IStripeAdapter stripeAdapter) : IBraintreeService
|
||||
{
|
||||
private readonly ConflictException _problemPayingInvoice = new("There was a problem paying for your invoice. Please contact customer support.");
|
||||
|
||||
public async Task PayInvoice(
|
||||
SubscriberId subscriberId,
|
||||
Invoice invoice)
|
||||
{
|
||||
if (invoice.Customer == null)
|
||||
{
|
||||
logger.LogError("Invoice's ({InvoiceID}) `customer` property must be expanded to be paid with Braintree",
|
||||
invoice.Id);
|
||||
throw _problemPayingInvoice;
|
||||
}
|
||||
|
||||
if (!invoice.Customer.Metadata.TryGetValue(MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId))
|
||||
{
|
||||
logger.LogError(
|
||||
"Cannot pay invoice ({InvoiceID}) with Braintree for Customer ({CustomerID}) that does not have a Braintree Customer ID",
|
||||
invoice.Id, invoice.Customer.Id);
|
||||
throw _problemPayingInvoice;
|
||||
}
|
||||
|
||||
if (invoice is not
|
||||
{
|
||||
AmountDue: > 0,
|
||||
Status: not InvoiceStatus.Paid,
|
||||
CollectionMethod: CollectionMethod.ChargeAutomatically
|
||||
})
|
||||
{
|
||||
logger.LogWarning("Attempted to pay invoice ({InvoiceID}) with Braintree that is not eligible for payment", invoice.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var amount = Math.Round(invoice.AmountDue / 100M, 2);
|
||||
|
||||
var idKey = subscriberId.Match(
|
||||
_ => "user_id",
|
||||
_ => "organization_id",
|
||||
_ => "provider_id");
|
||||
|
||||
var idValue = subscriberId.Match(
|
||||
userId => userId.Value,
|
||||
organizationId => organizationId.Value,
|
||||
providerId => providerId.Value);
|
||||
|
||||
var request = new TransactionRequest
|
||||
{
|
||||
Amount = amount,
|
||||
CustomerId = braintreeCustomerId,
|
||||
Options = new TransactionOptionsRequest
|
||||
{
|
||||
SubmitForSettlement = true,
|
||||
PayPal = new TransactionOptionsPayPalRequest
|
||||
{
|
||||
CustomField = $"{idKey}:{idValue},region:{globalSettings.BaseServiceUri.CloudRegion}"
|
||||
}
|
||||
},
|
||||
CustomFields = new Dictionary<string, string>
|
||||
{
|
||||
[idKey] = idValue.ToString(),
|
||||
["region"] = globalSettings.BaseServiceUri.CloudRegion
|
||||
}
|
||||
};
|
||||
|
||||
var result = await braintreeGateway.Transaction.SaleAsync(request);
|
||||
|
||||
if (!result.IsSuccess())
|
||||
{
|
||||
if (invoice.AttemptCount < 4)
|
||||
{
|
||||
await mailService.SendPaymentFailedAsync(invoice.Customer.Email, amount, true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await stripeAdapter.UpdateInvoiceAsync(invoice.Id, new InvoiceUpdateOptions
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[MetadataKeys.BraintreeTransactionId] = result.Target.Id,
|
||||
[MetadataKeys.PayPalTransactionId] = result.Target.PayPalDetails.AuthorizationId
|
||||
}
|
||||
});
|
||||
|
||||
await stripeAdapter.PayInvoiceAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true });
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Licenses.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
@@ -982,6 +984,16 @@ public class UserService : UserManager<User>, IUserService
|
||||
throw new BadRequestException(exceptionMessage);
|
||||
}
|
||||
|
||||
// If the license has a Token (claims-based), extract all properties from claims
|
||||
// Otherwise, fall back to using the properties already on the license object (backward compatibility)
|
||||
if (claimsPrincipal != null)
|
||||
{
|
||||
license.LicenseKey = claimsPrincipal.GetValue<string>(UserLicenseConstants.LicenseKey);
|
||||
license.Premium = claimsPrincipal.GetValue<bool>(UserLicenseConstants.Premium);
|
||||
license.MaxStorageGb = claimsPrincipal.GetValue<short?>(UserLicenseConstants.MaxStorageGb);
|
||||
license.Expires = claimsPrincipal.GetValue<DateTime?>(UserLicenseConstants.Expires);
|
||||
}
|
||||
|
||||
var dir = $"{_globalSettings.LicenseDirectory}/user";
|
||||
Directory.CreateDirectory(dir);
|
||||
using var fs = File.OpenWrite(Path.Combine(dir, $"{user.Id}.json"));
|
||||
@@ -995,6 +1007,7 @@ public class UserService : UserManager<User>, IUserService
|
||||
await SaveUserAsync(user);
|
||||
}
|
||||
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
public async Task<string> AdjustStorageAsync(User user, short storageAdjustmentGb)
|
||||
{
|
||||
if (user == null)
|
||||
@@ -1040,6 +1053,7 @@ public class UserService : UserManager<User>, IUserService
|
||||
await _paymentService.CancelSubscriptionAsync(user, eop);
|
||||
}
|
||||
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
public async Task ReinstatePremiumAsync(User user)
|
||||
{
|
||||
await _paymentService.ReinstateSubscriptionAsync(user);
|
||||
|
||||
23
src/Core/Services/Play/IPlayIdService.cs
Normal file
23
src/Core/Services/Play/IPlayIdService.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing Play identifiers in automated testing infrastructure.
|
||||
/// A "Play" is a test session that groups entities created during testing to enable cleanup.
|
||||
/// The PlayId flows from client request (x-play-id header) through PlayIdMiddleware to this service,
|
||||
/// which repositories query to create PlayItem tracking records via IPlayItemService. The SeederAPI uses these records
|
||||
/// to bulk delete all entities associated with a PlayId. Only active in Development environments.
|
||||
/// </summary>
|
||||
public interface IPlayIdService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current Play identifier from the x-play-id request header.
|
||||
/// </summary>
|
||||
string? PlayId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the current request is part of an active Play session.
|
||||
/// </summary>
|
||||
/// <param name="playId">The Play identifier if active, otherwise empty string.</param>
|
||||
/// <returns>True if in a Play session (has PlayId and in Development environment), otherwise false.</returns>
|
||||
bool InPlay(out string playId);
|
||||
}
|
||||
27
src/Core/Services/Play/IPlayItemService.cs
Normal file
27
src/Core/Services/Play/IPlayItemService.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service used to track added users and organizations during a Play session.
|
||||
/// </summary>
|
||||
public interface IPlayItemService
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a PlayItem entry for the given User created during a Play session.
|
||||
///
|
||||
/// Does nothing if no Play Id is set for this http scope.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <returns></returns>
|
||||
Task Record(User user);
|
||||
/// <summary>
|
||||
/// Records a PlayItem entry for the given Organization created during a Play session.
|
||||
///
|
||||
/// Does nothing if no Play Id is set for this http scope.
|
||||
/// </summary>
|
||||
/// <param name="organization"></param>
|
||||
/// <returns></returns>
|
||||
Task Record(Organization organization);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class NeverPlayIdServices : IPlayIdService
|
||||
{
|
||||
public string? PlayId
|
||||
{
|
||||
get => null;
|
||||
set { }
|
||||
}
|
||||
|
||||
public bool InPlay(out string playId)
|
||||
{
|
||||
playId = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
13
src/Core/Services/Play/Implementations/PlayIdService.cs
Normal file
13
src/Core/Services/Play/Implementations/PlayIdService.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class PlayIdService(IHostEnvironment hostEnvironment) : IPlayIdService
|
||||
{
|
||||
public string? PlayId { get; set; }
|
||||
public bool InPlay(out string playId)
|
||||
{
|
||||
playId = PlayId ?? string.Empty;
|
||||
return !string.IsNullOrEmpty(PlayId) && hostEnvironment.IsDevelopment();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton wrapper service that bridges singleton-scoped service boundaries for PlayId tracking.
|
||||
/// This allows singleton services to access the scoped PlayIdService via HttpContext.RequestServices.
|
||||
///
|
||||
/// Uses IHttpContextAccessor to retrieve the current request's scoped PlayIdService instance, enabling
|
||||
/// singleton services to participate in Play session tracking without violating DI lifetime rules.
|
||||
/// Falls back to NeverPlayIdServices when no HttpContext is available (e.g., background jobs).
|
||||
/// </summary>
|
||||
public class PlayIdSingletonService(IHttpContextAccessor httpContextAccessor, IHostEnvironment hostEnvironment) : IPlayIdService
|
||||
{
|
||||
private IPlayIdService Current
|
||||
{
|
||||
get
|
||||
{
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext == null)
|
||||
{
|
||||
return new NeverPlayIdServices();
|
||||
}
|
||||
return httpContext.RequestServices.GetRequiredService<PlayIdService>();
|
||||
}
|
||||
}
|
||||
|
||||
public string? PlayId
|
||||
{
|
||||
get => Current.PlayId;
|
||||
set => Current.PlayId = value;
|
||||
}
|
||||
|
||||
public bool InPlay(out string playId)
|
||||
{
|
||||
if (hostEnvironment.IsDevelopment())
|
||||
{
|
||||
return Current.InPlay(out playId);
|
||||
}
|
||||
else
|
||||
{
|
||||
playId = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/Core/Services/Play/Implementations/PlayItemService.cs
Normal file
26
src/Core/Services/Play/Implementations/PlayItemService.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class PlayItemService(IPlayIdService playIdService, IPlayItemRepository playItemRepository, ILogger<PlayItemService> logger) : IPlayItemService
|
||||
{
|
||||
public async Task Record(User user)
|
||||
{
|
||||
if (playIdService.InPlay(out var playId))
|
||||
{
|
||||
logger.LogInformation("Associating user {UserId} with Play ID {PlayId}", user.Id, playId);
|
||||
await playItemRepository.CreateAsync(PlayItem.Create(user, playId));
|
||||
}
|
||||
}
|
||||
public async Task Record(Organization organization)
|
||||
{
|
||||
if (playIdService.InPlay(out var playId))
|
||||
{
|
||||
logger.LogInformation("Associating organization {OrganizationId} with Play ID {PlayId}", organization.Id, playId);
|
||||
await playItemRepository.CreateAsync(PlayItem.Create(organization, playId));
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/Core/Services/Play/README.md
Normal file
27
src/Core/Services/Play/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Play Services
|
||||
|
||||
## Overview
|
||||
|
||||
The Play services provide automated testing infrastructure for tracking and cleaning up test data in development
|
||||
environments. A "Play" is a test session that groups entities (users, organizations, etc.) created during testing to
|
||||
enable bulk cleanup via the SeederAPI.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Test client sends `x-play-id` header with a unique Play identifier
|
||||
2. `PlayIdMiddleware` extracts the header and sets it on `IPlayIdService`
|
||||
3. Repositories check `IPlayIdService.InPlay()` when creating entities
|
||||
4. `IPlayItemService` records PlayItem entries for tracked entities
|
||||
5. SeederAPI uses PlayItem records to bulk delete all entities associated with a PlayId
|
||||
|
||||
Play services are **only active in Development environments**.
|
||||
|
||||
## Classes
|
||||
|
||||
- **`IPlayIdService`** - Interface for managing Play identifiers in the current request scope
|
||||
- **`IPlayItemService`** - Interface for tracking entities created during a Play session
|
||||
- **`PlayIdService`** - Default scoped implementation for tracking Play sessions per HTTP request
|
||||
- **`NeverPlayIdServices`** - No-op implementation used as fallback when no HttpContext is available
|
||||
- **`PlayIdSingletonService`** - Singleton wrapper that allows singleton services to access scoped PlayIdService via
|
||||
HttpContext
|
||||
- **`PlayItemService`** - Implementation that records PlayItem entries for entities created during Play sessions
|
||||
@@ -44,6 +44,7 @@ public class GlobalSettings : IGlobalSettings
|
||||
public virtual bool EnableCloudCommunication { get; set; } = false;
|
||||
public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days
|
||||
public virtual string EventGridKey { get; set; }
|
||||
public virtual bool TestPlayIdTrackingEnabled { get; set; } = false;
|
||||
public virtual IInstallationSettings Installation { get; set; } = new InstallationSettings();
|
||||
public virtual IBaseServiceUriSettings BaseServiceUri { get; set; }
|
||||
public virtual string DatabaseProvider { get; set; }
|
||||
|
||||
52
src/Core/Utilities/EnumMemberJsonConverter.cs
Normal file
52
src/Core/Utilities/EnumMemberJsonConverter.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Core.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// A custom JSON converter for enum types that respects the <see cref="EnumMemberAttribute"/> when serializing and deserializing.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The enum type to convert. Must be a struct and implement Enum.</typeparam>
|
||||
/// <remarks>
|
||||
/// This converter builds lookup dictionaries at initialization to efficiently map between enum values and their
|
||||
/// string representations. If an enum value has an <see cref="EnumMemberAttribute"/>, the attribute's Value
|
||||
/// property is used as the JSON string; otherwise, the enum's ToString() value is used.
|
||||
/// </remarks>
|
||||
public class EnumMemberJsonConverter<T> : JsonConverter<T> where T : struct, Enum
|
||||
{
|
||||
private readonly Dictionary<T, string> _enumToString = new();
|
||||
private readonly Dictionary<string, T> _stringToEnum = new();
|
||||
|
||||
public EnumMemberJsonConverter()
|
||||
{
|
||||
var type = typeof(T);
|
||||
var values = Enum.GetValues<T>();
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
var fieldInfo = type.GetField(value.ToString());
|
||||
var attribute = fieldInfo?.GetCustomAttribute<EnumMemberAttribute>();
|
||||
|
||||
var stringValue = attribute?.Value ?? value.ToString();
|
||||
_enumToString[value] = stringValue;
|
||||
_stringToEnum[stringValue] = value;
|
||||
}
|
||||
}
|
||||
|
||||
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var stringValue = reader.GetString();
|
||||
|
||||
if (!string.IsNullOrEmpty(stringValue) && _stringToEnum.TryGetValue(stringValue, out var enumValue))
|
||||
{
|
||||
return enumValue;
|
||||
}
|
||||
|
||||
throw new JsonException($"Unable to convert '{stringValue}' to {typeof(T).Name}");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(_enumToString[value]);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -30,6 +30,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Add event integration services
|
||||
services.AddDistributedCache(globalSettings);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Icons</UserSecretsId>
|
||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Icons' " />
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<PropertyGroup>
|
||||
<UserSecretsId>bitwarden-Identity</UserSecretsId>
|
||||
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Identity' " />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Api.Response;
|
||||
using Bit.Core.Auth.Utilities;
|
||||
@@ -8,7 +7,6 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Models.Api.Response;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Identity.Utilities;
|
||||
|
||||
@@ -26,8 +24,6 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
|
||||
private readonly IDeviceRepository _deviceRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly ILoginApprovingClientTypes _loginApprovingClientTypes;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
private UserDecryptionOptions _options = new UserDecryptionOptions();
|
||||
private User _user = null!;
|
||||
private SsoConfig? _ssoConfig;
|
||||
@@ -37,15 +33,13 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
|
||||
ICurrentContext currentContext,
|
||||
IDeviceRepository deviceRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ILoginApprovingClientTypes loginApprovingClientTypes,
|
||||
IFeatureService featureService
|
||||
ILoginApprovingClientTypes loginApprovingClientTypes
|
||||
)
|
||||
{
|
||||
_currentContext = currentContext;
|
||||
_deviceRepository = deviceRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_loginApprovingClientTypes = loginApprovingClientTypes;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
public IUserDecryptionOptionsBuilder ForUser(User user)
|
||||
@@ -145,34 +139,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
|
||||
// In the TDE flow, the users will have been JIT-provisioned at SSO callback time, and the relationship between
|
||||
// user and organization user will have been codified.
|
||||
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id);
|
||||
var hasManageResetPasswordPermission = false;
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword))
|
||||
{
|
||||
hasManageResetPasswordPermission = await EvaluateHasManageResetPasswordPermission();
|
||||
}
|
||||
else
|
||||
{
|
||||
// TODO: PM-26065 remove use of above feature flag from the server, and remove this branching logic, which
|
||||
// has been replaced by EvaluateHasManageResetPasswordPermission.
|
||||
// Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP.
|
||||
// When removing feature flags, please also see notes and removals intended for test suite in
|
||||
// Build_WhenManageResetPasswordPermissions_ShouldReturnHasManageResetPasswordPermissionTrue.
|
||||
|
||||
// when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here
|
||||
if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId))
|
||||
{
|
||||
// TDE requires single org so grabbing first org & id is fine.
|
||||
hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId);
|
||||
}
|
||||
|
||||
// If sso configuration data is not null then I know for sure that ssoConfiguration isn't null
|
||||
|
||||
// NOTE: Commented from original impl because the organization user repository call has been hoisted to support
|
||||
// branching paths through flagging.
|
||||
//organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id);
|
||||
|
||||
hasManageResetPasswordPermission |= organizationUser != null && (organizationUser.Type == OrganizationUserType.Owner || organizationUser.Type == OrganizationUserType.Admin);
|
||||
}
|
||||
var hasManageResetPasswordPermission = await EvaluateHasManageResetPasswordPermission();
|
||||
|
||||
// They are only able to be approved by an admin if they have enrolled is reset password
|
||||
var hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey);
|
||||
@@ -186,10 +153,10 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
|
||||
encryptedUserKey);
|
||||
return;
|
||||
|
||||
/// Determine if the user has manage reset password permission,
|
||||
/// as post-SSO logic requires it for forcing users with this permission to set a password.
|
||||
async Task<bool> EvaluateHasManageResetPasswordPermission()
|
||||
{
|
||||
// PM-23174
|
||||
// Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP
|
||||
if (organizationUser == null)
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -49,6 +49,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace Bit.Infrastructure.Dapper.Repositories;
|
||||
|
||||
public class OrganizationRepository : Repository<Organization, Guid>, IOrganizationRepository
|
||||
{
|
||||
private readonly ILogger<OrganizationRepository> _logger;
|
||||
protected readonly ILogger<OrganizationRepository> _logger;
|
||||
|
||||
public OrganizationRepository(
|
||||
GlobalSettings globalSettings,
|
||||
|
||||
@@ -51,6 +51,7 @@ public static class DapperServiceCollectionExtensions
|
||||
services.AddSingleton<IOrganizationRepository, OrganizationRepository>();
|
||||
services.AddSingleton<IOrganizationSponsorshipRepository, OrganizationSponsorshipRepository>();
|
||||
services.AddSingleton<IOrganizationUserRepository, OrganizationUserRepository>();
|
||||
services.AddSingleton<IPlayItemRepository, PlayItemRepository>();
|
||||
services.AddSingleton<IPolicyRepository, PolicyRepository>();
|
||||
services.AddSingleton<IProviderOrganizationRepository, ProviderOrganizationRepository>();
|
||||
services.AddSingleton<IProviderRepository, ProviderRepository>();
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Core\Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
45
src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs
Normal file
45
src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Data;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Infrastructure.Dapper.Repositories;
|
||||
|
||||
public class PlayItemRepository : Repository<PlayItem, Guid>, IPlayItemRepository
|
||||
{
|
||||
public PlayItemRepository(GlobalSettings globalSettings)
|
||||
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
|
||||
{ }
|
||||
|
||||
public PlayItemRepository(string connectionString, string readOnlyConnectionString)
|
||||
: base(connectionString, readOnlyConnectionString)
|
||||
{ }
|
||||
|
||||
public async Task<ICollection<PlayItem>> GetByPlayIdAsync(string playId)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.QueryAsync<PlayItem>(
|
||||
"[dbo].[PlayItem_ReadByPlayId]",
|
||||
new { PlayId = playId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteByPlayIdAsync(string playId)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"[dbo].[PlayItem_DeleteByPlayId]",
|
||||
new { PlayId = playId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ namespace Bit.Infrastructure.EntityFramework.Repositories;
|
||||
|
||||
public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Organization, Organization, Guid>, IOrganizationRepository
|
||||
{
|
||||
private readonly ILogger<OrganizationRepository> _logger;
|
||||
protected readonly ILogger<OrganizationRepository> _logger;
|
||||
|
||||
public OrganizationRepository(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Configurations;
|
||||
|
||||
public class PlayItemEntityTypeConfiguration : IEntityTypeConfiguration<PlayItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlayItem> builder)
|
||||
{
|
||||
builder
|
||||
.Property(pd => pd.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder
|
||||
.HasIndex(pd => pd.PlayId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.HasIndex(pd => pd.UserId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.HasIndex(pd => pd.OrganizationId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.HasOne(pd => pd.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(pd => pd.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder
|
||||
.HasOne(pd => pd.Organization)
|
||||
.WithMany()
|
||||
.HasForeignKey(pd => pd.OrganizationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder
|
||||
.ToTable(nameof(PlayItem))
|
||||
.HasCheckConstraint(
|
||||
"CK_PlayItem_UserOrOrganization",
|
||||
"(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ public static class EntityFrameworkServiceCollectionExtensions
|
||||
services.AddSingleton<IOrganizationRepository, OrganizationRepository>();
|
||||
services.AddSingleton<IOrganizationSponsorshipRepository, OrganizationSponsorshipRepository>();
|
||||
services.AddSingleton<IOrganizationUserRepository, OrganizationUserRepository>();
|
||||
services.AddSingleton<IPlayItemRepository, PlayItemRepository>();
|
||||
services.AddSingleton<IPolicyRepository, PolicyRepository>();
|
||||
services.AddSingleton<IProviderOrganizationRepository, ProviderOrganizationRepository>();
|
||||
services.AddSingleton<IProviderRepository, ProviderRepository>();
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
|
||||
|
||||
19
src/Infrastructure.EntityFramework/Models/PlayItem.cs
Normal file
19
src/Infrastructure.EntityFramework/Models/PlayItem.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
#nullable enable
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Models;
|
||||
|
||||
public class PlayItem : Core.Entities.PlayItem
|
||||
{
|
||||
public virtual User? User { get; set; }
|
||||
public virtual AdminConsole.Models.Organization? Organization { get; set; }
|
||||
}
|
||||
|
||||
public class PlayItemMapperProfile : Profile
|
||||
{
|
||||
public PlayItemMapperProfile()
|
||||
{
|
||||
CreateMap<Core.Entities.PlayItem, PlayItem>().ReverseMap();
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ public class DatabaseContext : DbContext
|
||||
public DbSet<OrganizationApiKey> OrganizationApiKeys { get; set; }
|
||||
public DbSet<OrganizationSponsorship> OrganizationSponsorships { get; set; }
|
||||
public DbSet<OrganizationConnection> OrganizationConnections { get; set; }
|
||||
public DbSet<PlayItem> PlayItem { get; set; }
|
||||
public DbSet<OrganizationIntegration> OrganizationIntegrations { get; set; }
|
||||
public DbSet<OrganizationIntegrationConfiguration> OrganizationIntegrationConfigurations { get; set; }
|
||||
public DbSet<OrganizationUser> OrganizationUsers { get; set; }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using AutoMapper;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Repositories;
|
||||
|
||||
public class PlayItemRepository : Repository<Core.Entities.PlayItem, PlayItem, Guid>, IPlayItemRepository
|
||||
{
|
||||
public PlayItemRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
|
||||
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.PlayItem)
|
||||
{ }
|
||||
|
||||
public async Task<ICollection<Core.Entities.PlayItem>> GetByPlayIdAsync(string playId)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var playItemEntities = await GetDbSet(dbContext)
|
||||
.Where(pd => pd.PlayId == playId)
|
||||
.ToListAsync();
|
||||
return Mapper.Map<List<Core.Entities.PlayItem>>(playItemEntities);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteByPlayIdAsync(string playId)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var entities = await GetDbSet(dbContext)
|
||||
.Where(pd => pd.PlayId == playId)
|
||||
.ToListAsync();
|
||||
|
||||
dbContext.PlayItem.RemoveRange(entities);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/SharedWeb/Play/PlayServiceCollectionExtensions.cs
Normal file
30
src/SharedWeb/Play/PlayServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.SharedWeb.Play.Repositories;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.SharedWeb.Play;
|
||||
|
||||
public static class PlayServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds PlayId tracking decorators for User and Organization repositories using Dapper implementations.
|
||||
/// This replaces the standard repository implementations with tracking versions
|
||||
/// that record created entities for test data cleanup. Only call when TestPlayIdTrackingEnabled is true.
|
||||
/// </summary>
|
||||
public static void AddPlayIdTrackingDapperRepositories(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IOrganizationRepository, DapperTestOrganizationTrackingOrganizationRepository>();
|
||||
services.AddSingleton<IUserRepository, DapperTestUserTrackingUserRepository>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds PlayId tracking decorators for User and Organization repositories using EntityFramework implementations.
|
||||
/// This replaces the standard repository implementations with tracking versions
|
||||
/// that record created entities for test data cleanup. Only call when TestPlayIdTrackingEnabled is true.
|
||||
/// </summary>
|
||||
public static void AddPlayIdTrackingEFRepositories(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IOrganizationRepository, EFTestOrganizationTrackingOrganizationRepository>();
|
||||
services.AddSingleton<IUserRepository, EFTestUserTrackingUserRepository>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Infrastructure.Dapper.Repositories;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.SharedWeb.Play.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper decorator around the <see cref="Bit.Infrastructure.Dapper.Repositories.OrganizationRepository"/> that tracks
|
||||
/// created Organizations for seeding.
|
||||
/// </summary>
|
||||
public class DapperTestOrganizationTrackingOrganizationRepository : OrganizationRepository
|
||||
{
|
||||
private readonly IPlayItemService _playItemService;
|
||||
|
||||
public DapperTestOrganizationTrackingOrganizationRepository(
|
||||
IPlayItemService playItemService,
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<OrganizationRepository> logger)
|
||||
: base(globalSettings, logger)
|
||||
{
|
||||
_playItemService = playItemService;
|
||||
}
|
||||
|
||||
public override async Task<Organization> CreateAsync(Organization obj)
|
||||
{
|
||||
var createdOrganization = await base.CreateAsync(obj);
|
||||
await _playItemService.Record(createdOrganization);
|
||||
return createdOrganization;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Infrastructure.Dapper.Repositories;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace Bit.SharedWeb.Play.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper decorator around the <see cref="Bit.Infrastructure.Dapper.Repositories.UserRepository"/> that tracks
|
||||
/// created Users for seeding.
|
||||
/// </summary>
|
||||
public class DapperTestUserTrackingUserRepository : UserRepository
|
||||
{
|
||||
private readonly IPlayItemService _playItemService;
|
||||
|
||||
public DapperTestUserTrackingUserRepository(
|
||||
IPlayItemService playItemService,
|
||||
GlobalSettings globalSettings,
|
||||
IDataProtectionProvider dataProtectionProvider)
|
||||
: base(globalSettings, dataProtectionProvider)
|
||||
{
|
||||
_playItemService = playItemService;
|
||||
}
|
||||
|
||||
public override async Task<User> CreateAsync(User user)
|
||||
{
|
||||
var createdUser = await base.CreateAsync(user);
|
||||
|
||||
await _playItemService.Record(createdUser);
|
||||
return createdUser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using AutoMapper;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.SharedWeb.Play.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EntityFramework decorator around the <see cref="Bit.Infrastructure.EntityFramework.Repositories.OrganizationRepository"/> that tracks
|
||||
/// created Organizations for seeding.
|
||||
/// </summary>
|
||||
public class EFTestOrganizationTrackingOrganizationRepository : OrganizationRepository
|
||||
{
|
||||
private readonly IPlayItemService _playItemService;
|
||||
|
||||
public EFTestOrganizationTrackingOrganizationRepository(
|
||||
IPlayItemService playItemService,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IMapper mapper,
|
||||
ILogger<OrganizationRepository> logger)
|
||||
: base(serviceScopeFactory, mapper, logger)
|
||||
{
|
||||
_playItemService = playItemService;
|
||||
}
|
||||
|
||||
public override async Task<Core.AdminConsole.Entities.Organization> CreateAsync(Core.AdminConsole.Entities.Organization organization)
|
||||
{
|
||||
var createdOrganization = await base.CreateAsync(organization);
|
||||
await _playItemService.Record(createdOrganization);
|
||||
return createdOrganization;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using AutoMapper;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.SharedWeb.Play.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EntityFramework decorator around the <see cref="Bit.Infrastructure.EntityFramework.Repositories.UserRepository"/> that tracks
|
||||
/// created Users for seeding.
|
||||
/// </summary>
|
||||
public class EFTestUserTrackingUserRepository : UserRepository
|
||||
{
|
||||
private readonly IPlayItemService _playItemService;
|
||||
|
||||
public EFTestUserTrackingUserRepository(
|
||||
IPlayItemService playItemService,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IMapper mapper)
|
||||
: base(serviceScopeFactory, mapper)
|
||||
{
|
||||
_playItemService = playItemService;
|
||||
}
|
||||
|
||||
public override async Task<Core.Entities.User> CreateAsync(Core.Entities.User user)
|
||||
{
|
||||
var createdUser = await base.CreateAsync(user);
|
||||
await _playItemService.Record(createdUser);
|
||||
return createdUser;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- These opt outs should be removed when all warnings are addressed -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Infrastructure.Dapper\Infrastructure.Dapper.csproj" />
|
||||
<ProjectReference Include="..\Core\Core.csproj" />
|
||||
|
||||
41
src/SharedWeb/Utilities/PlayIdMiddleware.cs
Normal file
41
src/SharedWeb/Utilities/PlayIdMiddleware.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Bit.SharedWeb.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware to extract the x-play-id header and set it in the PlayIdService.
|
||||
///
|
||||
/// PlayId is used in testing infrastructure to track data created during automated testing and fa cilitate cleanup.
|
||||
/// </summary>
|
||||
/// <param name="next"></param>
|
||||
public sealed class PlayIdMiddleware(RequestDelegate next)
|
||||
{
|
||||
private const int MaxPlayIdLength = 256;
|
||||
|
||||
public async Task Invoke(HttpContext context, PlayIdService playIdService)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue("x-play-id", out var playId))
|
||||
{
|
||||
var playIdValue = playId.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(playIdValue))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new { Error = "x-play-id header cannot be empty or whitespace" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (playIdValue.Length > MaxPlayIdLength)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new { Error = $"x-play-id header cannot exceed {MaxPlayIdLength} characters" });
|
||||
return;
|
||||
}
|
||||
|
||||
playIdService.PlayId = playIdValue;
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ using Bit.Core.Vault;
|
||||
using Bit.Core.Vault.Services;
|
||||
using Bit.Infrastructure.Dapper;
|
||||
using Bit.Infrastructure.EntityFramework;
|
||||
using Bit.SharedWeb.Play;
|
||||
using DnsClient;
|
||||
using Duende.IdentityModel;
|
||||
using LaunchDarkly.Sdk.Server;
|
||||
@@ -118,6 +119,40 @@ public static class ServiceCollectionExtensions
|
||||
return provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers test PlayId tracking services for test data management and cleanup.
|
||||
/// This infrastructure is isolated to test environments and enables tracking of test-generated entities.
|
||||
/// </summary>
|
||||
public static void AddTestPlayIdTracking(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
{
|
||||
if (globalSettings.TestPlayIdTrackingEnabled)
|
||||
{
|
||||
var (provider, _) = GetDatabaseProvider(globalSettings);
|
||||
|
||||
// Include PlayIdService for tracking Play Ids in repositories
|
||||
// We need the http context accessor to use the Singleton version, which pulls from the scoped version
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
services.AddSingleton<IPlayItemService, PlayItemService>();
|
||||
services.AddSingleton<IPlayIdService, PlayIdSingletonService>();
|
||||
services.AddScoped<PlayIdService>();
|
||||
|
||||
// Replace standard repositories with PlayId tracking decorators
|
||||
if (provider == SupportedDatabaseProviders.SqlServer)
|
||||
{
|
||||
services.AddPlayIdTrackingDapperRepositories();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddPlayIdTrackingEFRepositories();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IPlayIdService, NeverPlayIdServices>();
|
||||
}
|
||||
}
|
||||
|
||||
public static void AddBaseServices(this IServiceCollection services, IGlobalSettings globalSettings)
|
||||
{
|
||||
services.AddScoped<ICipherService, CipherService>();
|
||||
@@ -523,6 +558,10 @@ public static class ServiceCollectionExtensions
|
||||
IWebHostEnvironment env, GlobalSettings globalSettings)
|
||||
{
|
||||
app.UseMiddleware<RequestLoggingMiddleware>();
|
||||
if (globalSettings.TestPlayIdTrackingEnabled)
|
||||
{
|
||||
app.UseMiddleware<PlayIdMiddleware>();
|
||||
}
|
||||
}
|
||||
|
||||
public static void UseForwardedHeaders(this IApplicationBuilder app, IGlobalSettings globalSettings)
|
||||
|
||||
27
src/Sql/dbo/Stored Procedures/PlayItem_Create.sql
Normal file
27
src/Sql/dbo/Stored Procedures/PlayItem_Create.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
CREATE PROCEDURE [dbo].[PlayItem_Create]
|
||||
@Id UNIQUEIDENTIFIER OUTPUT,
|
||||
@PlayId NVARCHAR(256),
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@CreationDate DATETIME2(7)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
INSERT INTO [dbo].[PlayItem]
|
||||
(
|
||||
[Id],
|
||||
[PlayId],
|
||||
[UserId],
|
||||
[OrganizationId],
|
||||
[CreationDate]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@Id,
|
||||
@PlayId,
|
||||
@UserId,
|
||||
@OrganizationId,
|
||||
@CreationDate
|
||||
)
|
||||
END
|
||||
12
src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql
Normal file
12
src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE PROCEDURE [dbo].[PlayItem_DeleteByPlayId]
|
||||
@PlayId NVARCHAR(256)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[PlayItem]
|
||||
WHERE
|
||||
[PlayId] = @PlayId
|
||||
END
|
||||
17
src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql
Normal file
17
src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
CREATE PROCEDURE [dbo].[PlayItem_ReadByPlayId]
|
||||
@PlayId NVARCHAR(256)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
[Id],
|
||||
[PlayId],
|
||||
[UserId],
|
||||
[OrganizationId],
|
||||
[CreationDate]
|
||||
FROM
|
||||
[dbo].[PlayItem]
|
||||
WHERE
|
||||
[PlayId] = @PlayId
|
||||
END
|
||||
23
src/Sql/dbo/Tables/PlayItem.sql
Normal file
23
src/Sql/dbo/Tables/PlayItem.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE [dbo].[PlayItem] (
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[PlayId] NVARCHAR (256) NOT NULL,
|
||||
[UserId] UNIQUEIDENTIFIER NULL,
|
||||
[OrganizationId] UNIQUEIDENTIFIER NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
CONSTRAINT [PK_PlayItem] PRIMARY KEY CLUSTERED ([Id] ASC),
|
||||
CONSTRAINT [FK_PlayItem_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE,
|
||||
CONSTRAINT [FK_PlayItem_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE,
|
||||
CONSTRAINT [CK_PlayItem_UserOrOrganization] CHECK (([UserId] IS NOT NULL AND [OrganizationId] IS NULL) OR ([UserId] IS NULL AND [OrganizationId] IS NOT NULL))
|
||||
);
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [IX_PlayItem_PlayId]
|
||||
ON [dbo].[PlayItem]([PlayId] ASC);
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [IX_PlayItem_UserId]
|
||||
ON [dbo].[PlayItem]([UserId] ASC);
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [IX_PlayItem_OrganizationId]
|
||||
ON [dbo].[PlayItem]([OrganizationId] ASC);
|
||||
Reference in New Issue
Block a user