1
0
mirror of https://github.com/bitwarden/server synced 2026-01-02 08:33:48 +00:00

Merge branch 'main' into auth/pm-22975/client-version-validator

This commit is contained in:
Patrick Pimentel
2025-11-20 14:49:35 -05:00
163 changed files with 16066 additions and 885 deletions

View File

@@ -27,6 +27,7 @@
},
"storage": {
"connectionString": "UseDevelopmentStorage=true"
}
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}
}

View File

@@ -0,0 +1,26 @@
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Models.Api;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
public abstract class BaseAdminConsoleController : Controller
{
protected static IResult Handle(CommandResult commandResult) =>
commandResult.Match<IResult>(
error => error switch
{
BadRequestError badRequest => TypedResults.BadRequest(new ErrorResponseModel(badRequest.Message)),
NotFoundError notFound => TypedResults.NotFound(new ErrorResponseModel(notFound.Message)),
InternalError internalError => TypedResults.Json(
new ErrorResponseModel(internalError.Message),
statusCode: StatusCodes.Status500InternalServerError),
_ => TypedResults.Json(
new ErrorResponseModel(error.Message),
statusCode: StatusCodes.Status500InternalServerError
)
},
_ => TypedResults.NoContent()
);
}

View File

@@ -11,8 +11,10 @@ using Bit.Api.Models.Response;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
@@ -20,6 +22,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Pricing;
@@ -43,7 +46,7 @@ namespace Bit.Api.AdminConsole.Controllers;
[Route("organizations/{orgId}/users")]
[Authorize("Application")]
public class OrganizationUsersController : Controller
public class OrganizationUsersController : BaseAdminConsoleController
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
@@ -68,6 +71,7 @@ public class OrganizationUsersController : Controller
private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand;
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
@@ -101,7 +105,8 @@ public class OrganizationUsersController : Controller
IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand)
IAdminRecoverAccountCommand adminRecoverAccountCommand,
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -126,6 +131,7 @@ public class OrganizationUsersController : Controller
_featureService = featureService;
_pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand;
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_initPendingOrganizationCommand = initPendingOrganizationCommand;
@@ -738,6 +744,31 @@ public class OrganizationUsersController : Controller
await BulkEnableSecretsManagerAsync(orgId, model);
}
[HttpPost("{id}/auto-confirm")]
[Authorize<ManageUsersRequirement>]
[RequireFeature(FeatureFlagKeys.AutomaticConfirmUsers)]
public async Task<IResult> AutomaticallyConfirmOrganizationUserAsync([FromRoute] Guid orgId,
[FromRoute] Guid id,
[FromBody] OrganizationUserConfirmRequestModel model)
{
var userId = _userService.GetProperUserId(User);
if (userId is null || userId.Value == Guid.Empty)
{
return TypedResults.Unauthorized();
}
return Handle(await _automaticallyConfirmOrganizationUserCommand.AutomaticallyConfirmOrganizationUserAsync(
new AutomaticallyConfirmOrganizationUserRequest
{
OrganizationId = orgId,
OrganizationUserId = id,
Key = model.Key,
DefaultUserCollectionName = model.DefaultUserCollectionName,
PerformedBy = new StandardUser(userId.Value, await _currentContext.OrganizationOwner(orgId)),
}));
}
private async Task RestoreOrRevokeUserAsync(
Guid orgId,
Guid id,

View File

@@ -30,6 +30,7 @@ public class PolicyResponseModel : ResponseModel
{
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(policy.Data);
}
RevisionDate = policy.RevisionDate;
}
public Guid Id { get; set; }
@@ -37,4 +38,5 @@ public class PolicyResponseModel : ResponseModel
public PolicyType Type { get; set; }
public Dictionary<string, object> Data { get; set; }
public bool Enabled { get; set; }
public DateTime RevisionDate { get; set; }
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Utilities;
@@ -27,7 +28,7 @@ public class ProfileOrganizationResponseModel : BaseProfileOrganizationResponseM
FamilySponsorshipToDelete = organizationDetails.FamilySponsorshipToDelete;
FamilySponsorshipValidUntil = organizationDetails.FamilySponsorshipValidUntil;
FamilySponsorshipAvailable = (organizationDetails.FamilySponsorshipFriendlyName == null || IsAdminInitiated) &&
StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise)
SponsoredPlans.Get(PlanSponsorshipType.FamiliesForEnterprise)
.UsersCanSponsor(organizationDetails);
AccessSecretsManager = organizationDetails.AccessSecretsManager;
}

View File

@@ -0,0 +1,35 @@
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization.Requirements;
using Bit.Api.Billing.Attributes;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Bit.Api.Billing.Controllers.VNext;
[Authorize("Application")]
[Route("organizations/{organizationId:guid}/billing/vnext/self-host")]
[SelfHosted(SelfHostedOnly = true)]
public class SelfHostedBillingController(
IGetOrganizationMetadataQuery getOrganizationMetadataQuery) : BaseBillingController
{
[Authorize<MemberOrProviderRequirement>]
[HttpGet("metadata")]
[RequireFeature(FeatureFlagKeys.PM25379_UseNewOrganizationMetadataStructure)]
[InjectOrganization]
public async Task<IResult> GetMetadataAsync([BindNever] Organization organization)
{
var metadata = await getOrganizationMetadataQuery.Run(organization);
if (metadata == null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(metadata);
}
}

View File

@@ -1422,11 +1422,9 @@ public class CiphersController : Controller
throw new NotFoundException();
}
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (stream) =>
{
await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData, lastKnownRevisionDate);
await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData);
});
}
@@ -1525,13 +1523,10 @@ public class CiphersController : Controller
throw new NotFoundException();
}
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (stream, fileName, key) =>
{
await _cipherService.CreateAttachmentShareAsync(cipher, stream, fileName, key,
Request.ContentLength.GetValueOrDefault(0), attachmentId, organizationId, lastKnownRevisionDate);
Request.ContentLength.GetValueOrDefault(0), attachmentId, organizationId);
});
}

View File

@@ -41,6 +41,7 @@
"phishingDomain": {
"updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt",
"checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256"
}
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}
}

View File

@@ -3,12 +3,12 @@
using Bit.Billing.Constants;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Braintree;
using Stripe;
using Customer = Stripe.Customer;
@@ -112,7 +112,7 @@ public class StripeEventUtilityService : IStripeEventUtilityService
}
public bool IsSponsoredSubscription(Subscription subscription) =>
StaticStore.SponsoredPlans
SponsoredPlans.All
.Any(p => subscription.Items
.Any(i => i.Plan.Id == p.StripePlanId));

View File

@@ -195,41 +195,48 @@ public class UpcomingInvoiceHandler(
Plan plan,
bool milestone3)
{
if (milestone3 && plan.Type == PlanType.FamiliesAnnually2019)
// currently these are the only plans that need aligned and both require the same flag and share most of the logic
if (!milestone3 || plan.Type is not (PlanType.FamiliesAnnually2019 or PlanType.FamiliesAnnually2025))
{
var passwordManagerItem =
subscription.Items.FirstOrDefault(item => item.Price.Id == plan.PasswordManager.StripePlanId);
return;
}
if (passwordManagerItem == null)
{
logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})",
organization.Id, @event.Type, @event.Id);
return;
}
var passwordManagerItem =
subscription.Items.FirstOrDefault(item => item.Price.Id == plan.PasswordManager.StripePlanId);
var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
if (passwordManagerItem == null)
{
logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})",
organization.Id, @event.Type, @event.Id);
return;
}
organization.PlanType = families.Type;
organization.Plan = families.Name;
organization.UsersGetPremium = families.UsersGetPremium;
organization.Seats = families.PasswordManager.BaseSeats;
var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
var options = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
organization.PlanType = families.Type;
organization.Plan = families.Name;
organization.UsersGetPremium = families.UsersGetPremium;
organization.Seats = families.PasswordManager.BaseSeats;
var options = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Price = families.PasswordManager.StripePlanId
}
],
Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount }
],
ProrationBehavior = ProrationBehavior.None
};
}
],
ProrationBehavior = ProrationBehavior.None
};
if (plan.Type == PlanType.FamiliesAnnually2019)
{
options.Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount }
];
var premiumAccessAddOnItem = subscription.Items.FirstOrDefault(item =>
item.Price.Id == plan.PasswordManager.StripePremiumAccessPlanId);
@@ -253,21 +260,21 @@ public class UpcomingInvoiceHandler(
Deleted = true
});
}
}
try
{
await organizationRepository.ReplaceAsync(organization);
await stripeFacade.UpdateSubscription(subscription.Id, options);
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to align subscription concerns for Organization ({OrganizationID}) while processing '{EventType}' event ({EventID})",
organization.Id,
@event.Type,
@event.Id);
}
try
{
await organizationRepository.ReplaceAsync(organization);
await stripeFacade.UpdateSubscription(subscription.Id, options);
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to align subscription concerns for Organization ({OrganizationID}) while processing '{EventType}' event ({EventID})",
organization.Id,
@event.Type,
@event.Id);
}
}

View File

@@ -35,6 +35,7 @@
"billingSettings": {
"onyx": {
"personaId": 68
}
}
}
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}

View File

@@ -60,6 +60,7 @@ public enum EventType : int
OrganizationUser_RejectedAuthRequest = 1514,
OrganizationUser_Deleted = 1515, // Both user and organization user data were deleted
OrganizationUser_Left = 1516, // User voluntarily left the organization
OrganizationUser_AutomaticallyConfirmed = 1517,
Organization_Updated = 1600,
Organization_PurgedVault = 1601,

View File

@@ -21,6 +21,7 @@ public enum PolicyType : byte
UriMatchDefaults = 16,
AutotypeDefaultSetting = 17,
AutomaticUserConfirmation = 18,
BlockClaimedDomainAccountCreation = 19,
}
public static class PolicyTypeExtensions
@@ -52,6 +53,7 @@ public static class PolicyTypeExtensions
PolicyType.UriMatchDefaults => "URI match defaults",
PolicyType.AutotypeDefaultSetting => "Autotype default setting",
PolicyType.AutomaticUserConfirmation => "Automatically confirm invited users",
PolicyType.BlockClaimedDomainAccountCreation => "Block account creation for claimed domains",
};
}
}

View File

@@ -0,0 +1,8 @@
namespace Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
public record AcceptedOrganizationUserToConfirm
{
public required Guid OrganizationUserId { get; init; }
public required Guid UserId { get; init; }
public required string Key { get; init; }
}

View File

@@ -0,0 +1,186 @@
using Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using OneOf.Types;
using CommandResult = Bit.Core.AdminConsole.Utilities.v2.Results.CommandResult;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IAutomaticallyConfirmOrganizationUsersValidator validator,
IEventService eventService,
IMailService mailService,
IUserRepository userRepository,
IPushRegistrationService pushRegistrationService,
IDeviceRepository deviceRepository,
IPushNotificationService pushNotificationService,
IPolicyRequirementQuery policyRequirementQuery,
ICollectionRepository collectionRepository,
TimeProvider timeProvider,
ILogger<AutomaticallyConfirmOrganizationUserCommand> logger) : IAutomaticallyConfirmOrganizationUserCommand
{
public async Task<CommandResult> AutomaticallyConfirmOrganizationUserAsync(AutomaticallyConfirmOrganizationUserRequest request)
{
var validatorRequest = await RetrieveDataAsync(request);
var validatedData = await validator.ValidateAsync(validatorRequest);
return await validatedData.Match<Task<CommandResult>>(
error => Task.FromResult(new CommandResult(error)),
async _ =>
{
var userToConfirm = new AcceptedOrganizationUserToConfirm
{
OrganizationUserId = validatedData.Request.OrganizationUser!.Id,
UserId = validatedData.Request.OrganizationUser.UserId!.Value,
Key = validatedData.Request.Key
};
// This operation is idempotent. If false, the user is already confirmed and no additional side effects are required.
if (!await organizationUserRepository.ConfirmOrganizationUserAsync(userToConfirm))
{
return new None();
}
await CreateDefaultCollectionsAsync(validatedData.Request);
await Task.WhenAll(
LogOrganizationUserConfirmedEventAsync(validatedData.Request),
SendConfirmedOrganizationUserEmailAsync(validatedData.Request),
SyncOrganizationKeysAsync(validatedData.Request)
);
return new None();
}
);
}
private async Task SyncOrganizationKeysAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
await DeleteDeviceRegistrationAsync(request);
await PushSyncOrganizationKeysAsync(request);
}
private async Task CreateDefaultCollectionsAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
try
{
if (!await ShouldCreateDefaultCollectionAsync(request))
{
return;
}
await collectionRepository.CreateAsync(
new Collection
{
OrganizationId = request.Organization!.Id,
Name = request.DefaultUserCollectionName,
Type = CollectionType.DefaultUserCollection
},
groups: null,
[new CollectionAccessSelection
{
Id = request.OrganizationUser!.Id,
Manage = true
}]);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create default collection for user.");
}
}
/// <summary>
/// Determines whether a default collection should be created for an organization user during the confirmation process.
/// </summary>
/// <param name="request">
/// The validation request containing information about the user, organization, and collection settings.
/// </param>
/// <returns>The result is a boolean value indicating whether a default collection should be created.</returns>
private async Task<bool> ShouldCreateDefaultCollectionAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) =>
!string.IsNullOrWhiteSpace(request.DefaultUserCollectionName)
&& (await policyRequirementQuery.GetAsync<OrganizationDataOwnershipPolicyRequirement>(request.OrganizationUser!.UserId!.Value))
.RequiresDefaultCollectionOnConfirm(request.Organization!.Id);
private async Task PushSyncOrganizationKeysAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
try
{
await pushNotificationService.PushSyncOrgKeysAsync(request.OrganizationUser!.UserId!.Value);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to push organization keys.");
}
}
private async Task LogOrganizationUserConfirmedEventAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
try
{
await eventService.LogOrganizationUserEventAsync(request.OrganizationUser,
EventType.OrganizationUser_AutomaticallyConfirmed,
timeProvider.GetUtcNow().UtcDateTime);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to log OrganizationUser_AutomaticallyConfirmed event.");
}
}
private async Task SendConfirmedOrganizationUserEmailAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
try
{
var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value);
await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name,
user!.Email,
request.OrganizationUser.AccessSecretsManager);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send OrganizationUserConfirmed.");
}
}
private async Task DeleteDeviceRegistrationAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
try
{
var devices = (await deviceRepository.GetManyByUserIdAsync(request.OrganizationUser!.UserId!.Value))
.Where(d => !string.IsNullOrWhiteSpace(d.PushToken))
.Select(d => d.Id.ToString());
await pushRegistrationService.DeleteUserRegistrationOrganizationAsync(devices, request.Organization!.Id.ToString());
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete device registration.");
}
}
private async Task<AutomaticallyConfirmOrganizationUserValidationRequest> RetrieveDataAsync(
AutomaticallyConfirmOrganizationUserRequest request)
{
return new AutomaticallyConfirmOrganizationUserValidationRequest
{
OrganizationUserId = request.OrganizationUserId,
OrganizationId = request.OrganizationId,
Key = request.Key,
DefaultUserCollectionName = request.DefaultUserCollectionName,
PerformedBy = request.PerformedBy,
OrganizationUser = await organizationUserRepository.GetByIdAsync(request.OrganizationUserId),
Organization = await organizationRepository.GetByIdAsync(request.OrganizationId)
};
}
}

View File

@@ -0,0 +1,29 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
/// <summary>
/// Automatically Confirm User Command Request
/// </summary>
public record AutomaticallyConfirmOrganizationUserRequest
{
public required Guid OrganizationUserId { get; init; }
public required Guid OrganizationId { get; init; }
public required string Key { get; init; }
public required string DefaultUserCollectionName { get; init; }
public required IActingUser PerformedBy { get; init; }
}
/// <summary>
/// Automatically Confirm User Validation Request
/// </summary>
/// <remarks>
/// This is used to hold retrieved data and pass it to the validator
/// </remarks>
public record AutomaticallyConfirmOrganizationUserValidationRequest : AutomaticallyConfirmOrganizationUserRequest
{
public OrganizationUser? OrganizationUser { get; set; }
public Organization? Organization { get; set; }
}

View File

@@ -0,0 +1,116 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
public class AutomaticallyConfirmOrganizationUsersValidator(
IOrganizationUserRepository organizationUserRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IPolicyRequirementQuery policyRequirementQuery,
IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator
{
public async Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync(
AutomaticallyConfirmOrganizationUserValidationRequest request)
{
// User must exist
if (request is { OrganizationUser: null } || request.OrganizationUser is { UserId: null })
{
return Invalid(request, new UserNotFoundError());
}
// Organization must exist
if (request is { Organization: null })
{
return Invalid(request, new OrganizationNotFound());
}
// User must belong to the organization
if (request.OrganizationUser.OrganizationId != request.Organization.Id)
{
return Invalid(request, new OrganizationUserIdIsInvalid());
}
// User must be accepted
if (request is { OrganizationUser.Status: not OrganizationUserStatusType.Accepted })
{
return Invalid(request, new UserIsNotAccepted());
}
// User must be of type User
if (request is { OrganizationUser.Type: not OrganizationUserType.User })
{
return Invalid(request, new UserIsNotUserType());
}
if (!await OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(request))
{
return Invalid(request, new AutomaticallyConfirmUsersPolicyIsNotEnabled());
}
if (!await OrganizationUserConformsToTwoFactorRequiredPolicyAsync(request))
{
return Invalid(request, new UserDoesNotHaveTwoFactorEnabled());
}
if (await OrganizationUserConformsToSingleOrgPolicyAsync(request) is { } error)
{
return Invalid(request, error);
}
return Valid(request);
}
private async Task<bool> OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(
AutomaticallyConfirmOrganizationUserValidationRequest request) =>
await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId,
PolicyType.AutomaticUserConfirmation) is { Enabled: true }
&& request.Organization is { UseAutomaticUserConfirmation: true };
private async Task<bool> OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)
{
if ((await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync([request.OrganizationUser!.UserId!.Value]))
.Any(x => x.userId == request.OrganizationUser.UserId && x.twoFactorIsEnabled))
{
return true;
}
return !(await policyRequirementQuery.GetAsync<RequireTwoFactorPolicyRequirement>(request.OrganizationUser.UserId!.Value))
.IsTwoFactorRequiredForOrganization(request.Organization!.Id);
}
private async Task<Error?> OrganizationUserConformsToSingleOrgPolicyAsync(
AutomaticallyConfirmOrganizationUserValidationRequest request)
{
var allOrganizationUsersForUser = await organizationUserRepository
.GetManyByUserAsync(request.OrganizationUser!.UserId!.Value);
if (allOrganizationUsersForUser.Count == 1)
{
return null;
}
var policyRequirement = await policyRequirementQuery
.GetAsync<SingleOrganizationPolicyRequirement>(request.OrganizationUser!.UserId!.Value);
if (policyRequirement.IsSingleOrgEnabledForThisOrganization(request.Organization!.Id))
{
return new OrganizationEnforcesSingleOrgPolicy();
}
if (policyRequirement.IsSingleOrgEnabledForOrganizationsOtherThan(request.Organization.Id))
{
return new OtherOrganizationEnforcesSingleOrgPolicy();
}
return null;
}
}

View File

@@ -0,0 +1,13 @@
using Bit.Core.AdminConsole.Utilities.v2;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
public record OrganizationNotFound() : NotFoundError("Invalid organization");
public record FailedToWriteToEventLog() : InternalError("Failed to write to event log");
public record UserIsNotUserType() : BadRequestError("Only organization users with the User role can be automatically confirmed");
public record UserIsNotAccepted() : BadRequestError("Cannot confirm user that has not accepted the invitation.");
public record OrganizationUserIdIsInvalid() : BadRequestError("Invalid organization user id.");
public record UserDoesNotHaveTwoFactorEnabled() : BadRequestError("User does not have two-step login enabled.");
public record OrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization until they leave or remove all other organizations");
public record OtherOrganizationEnforcesSingleOrgPolicy() : BadRequestError("Cannot confirm this member to the organization because they are in another organization which forbids it.");
public record AutomaticallyConfirmUsersPolicyIsNotEnabled() : BadRequestError("Cannot confirm this member because the Automatically Confirm Users policy is not enabled.");

View File

@@ -0,0 +1,9 @@
using Bit.Core.AdminConsole.Utilities.v2.Validation;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
public interface IAutomaticallyConfirmOrganizationUsersValidator
{
Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync(
AutomaticallyConfirmOrganizationUserValidationRequest request);
}

View File

@@ -1,4 +1,6 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;

View File

@@ -1,8 +1,9 @@
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount.ValidationResultHelpers;
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;

View File

@@ -1,15 +1,6 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.Utilities.v2;
/// <summary>
/// A strongly typed error containing a reason that an action failed.
/// This is used for business logic validation and other expected errors, not exceptions.
/// </summary>
public abstract record Error(string Message);
/// <summary>
/// An <see cref="Error"/> type that maps to a NotFoundResult at the api layer.
/// </summary>
/// <param name="Message"></param>
public abstract record NotFoundError(string Message) : Error(Message);
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
public record UserNotFoundError() : NotFoundError("Invalid user.");
public record UserNotClaimedError() : Error("Member is not claimed by the organization.");

View File

@@ -1,4 +1,6 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.Utilities.v2.Results;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
public interface IDeleteClaimedOrganizationUserAccountCommand
{

View File

@@ -1,4 +1,6 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
public interface IDeleteClaimedOrganizationUserAccountValidator
{

View File

@@ -0,0 +1,40 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
using Bit.Core.AdminConsole.Utilities.v2.Results;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
/// <summary>
/// Command to automatically confirm an organization user.
/// </summary>
/// <remarks>
/// The auto-confirm feature enables eligible client apps to confirm OrganizationUsers
/// automatically via push notifications, eliminating the need for manual administrator
/// intervention. Client apps receive a push notification, perform the required key exchange,
/// and submit an auto-confirm request to the server. This command processes those
/// client-initiated requests and should only be used in that specific context.
/// </remarks>
public interface IAutomaticallyConfirmOrganizationUserCommand
{
/// <summary>
/// Automatically confirms the organization user based on the provided request data.
/// </summary>
/// <param name="request">The request containing necessary information to confirm the organization user.</param>
/// <remarks>
/// This action has side effects. The side effects are
/// <ul>
/// <li>Creating an event log entry.</li>
/// <li>Syncing organization keys with the user.</li>
/// <li>Deleting any registered user devices for the organization.</li>
/// <li>Sending an email to the confirmed user.</li>
/// <li>Creating the default collection if applicable.</li>
/// </ul>
///
/// Each of these actions is performed independently of each other and not guaranteed to be performed in any order.
/// Errors will be reported back for the actions that failed in a consolidated error message.
/// </remarks>
/// <returns>
/// The result of the command. If there was an error, the result will contain a typed error describing the problem
/// that occurred.
/// </returns>
Task<CommandResult> AutomaticallyConfirmOrganizationUserAsync(AutomaticallyConfirmOrganizationUserRequest request);
}

View File

@@ -0,0 +1,21 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
public class SingleOrganizationPolicyRequirement(IEnumerable<PolicyDetails> policyDetails) : IPolicyRequirement
{
public bool IsSingleOrgEnabledForThisOrganization(Guid organizationId) =>
policyDetails.Any(p => p.OrganizationId == organizationId);
public bool IsSingleOrgEnabledForOrganizationsOtherThan(Guid organizationId) =>
policyDetails.Any(p => p.OrganizationId != organizationId);
}
public class SingleOrganizationPolicyRequirementFactory : BasePolicyRequirementFactory<SingleOrganizationPolicyRequirement>
{
public override PolicyType PolicyType => PolicyType.SingleOrg;
public override SingleOrganizationPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails) =>
new(policyDetails);
}

View File

@@ -53,6 +53,7 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyUpdateEvent, FreeFamiliesForEnterprisePolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, OrganizationDataOwnershipPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, UriMatchDefaultPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, BlockClaimedDomainAccountCreationPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, AutomaticUserConfirmationPolicyEventHandler>();
}
@@ -65,5 +66,6 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireSsoPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireTwoFactorPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, MasterPasswordPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SingleOrganizationPolicyRequirementFactory>();
}
}

View File

@@ -0,0 +1,59 @@
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
public class BlockClaimedDomainAccountCreationPolicyValidator : IPolicyValidator, IPolicyValidationEvent
{
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
private readonly IFeatureService _featureService;
public BlockClaimedDomainAccountCreationPolicyValidator(
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
IFeatureService featureService)
{
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_featureService = featureService;
}
public PolicyType Type => PolicyType.BlockClaimedDomainAccountCreation;
// No prerequisites - this policy stands alone
public IEnumerable<PolicyType> RequiredPolicies => [];
public async Task<string> ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy)
{
return await ValidateAsync(policyRequest.PolicyUpdate, currentPolicy);
}
public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{
// Check if feature is enabled
if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation))
{
return "This feature is not enabled";
}
// Only validate when trying to ENABLE the policy
if (policyUpdate is { Enabled: true })
{
// Check if organization has at least one verified domain
if (!await _organizationHasVerifiedDomainsQuery.HasVerifiedDomainsAsync(policyUpdate.OrganizationId))
{
return "You must claim at least one domain to turn on this policy";
}
}
// Disabling the policy is always allowed
return string.Empty;
}
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
=> Task.CompletedTask;
}

View File

@@ -1,6 +1,4 @@
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
@@ -29,8 +27,6 @@ public class SingleOrgPolicyValidator : IPolicyValidator, IPolicyValidationEvent
private readonly IOrganizationRepository _organizationRepository;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery;
private readonly IRevokeNonCompliantOrganizationUserCommand _revokeNonCompliantOrganizationUserCommand;
@@ -40,8 +36,6 @@ public class SingleOrgPolicyValidator : IPolicyValidator, IPolicyValidationEvent
IOrganizationRepository organizationRepository,
ISsoConfigRepository ssoConfigRepository,
ICurrentContext currentContext,
IFeatureService featureService,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand)
{
@@ -50,8 +44,6 @@ public class SingleOrgPolicyValidator : IPolicyValidator, IPolicyValidationEvent
_organizationRepository = organizationRepository;
_ssoConfigRepository = ssoConfigRepository;
_currentContext = currentContext;
_featureService = featureService;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_revokeNonCompliantOrganizationUserCommand = revokeNonCompliantOrganizationUserCommand;
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -93,7 +94,7 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
///
/// This is an idempotent operation.
/// </summary>
/// <param name="organizationUser">Accepted OrganizationUser to confirm</param>
/// <param name="organizationUserToConfirm">Accepted OrganizationUser to confirm</param>
/// <returns>True, if the user was updated. False, if not performed.</returns>
Task<bool> ConfirmOrganizationUserAsync(OrganizationUser organizationUser);
Task<bool> ConfirmOrganizationUserAsync(AcceptedOrganizationUserToConfirm organizationUserToConfirm);
}

View File

@@ -0,0 +1,15 @@
namespace Bit.Core.AdminConsole.Utilities.v2;
/// <summary>
/// A strongly typed error containing a reason that an action failed.
/// This is used for business logic validation and other expected errors, not exceptions.
/// </summary>
public abstract record Error(string Message);
/// <summary>
/// An <see cref="Error"/> type that maps to a NotFoundResult at the api layer.
/// </summary>
/// <param name="Message"></param>
public abstract record NotFoundError(string Message) : Error(Message);
public abstract record BadRequestError(string Message) : Error(Message);
public abstract record InternalError(string Message) : Error(Message);

View File

@@ -1,7 +1,7 @@
using OneOf;
using OneOf.Types;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
namespace Bit.Core.AdminConsole.Utilities.v2.Results;
/// <summary>
/// Represents the result of a command.
@@ -39,4 +39,3 @@ public record BulkCommandResult<T>(Guid Id, CommandResult<T> Result);
/// A wrapper for <see cref="CommandResult"/> with an ID, to identify the result in bulk operations.
/// </summary>
public record BulkCommandResult(Guid Id, CommandResult Result);

View File

@@ -1,7 +1,7 @@
using OneOf;
using OneOf.Types;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
namespace Bit.Core.AdminConsole.Utilities.v2.Validation;
/// <summary>
/// Represents the result of validating a request.

View File

@@ -15,16 +15,20 @@ using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Bit.Core.Auth.UserFeatures.Registration.Implementations;
public class RegisterUserCommand : IRegisterUserCommand
{
private readonly ILogger<RegisterUserCommand> _logger;
private readonly IGlobalSettings _globalSettings;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IOrganizationDomainRepository _organizationDomainRepository;
private readonly IFeatureService _featureService;
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;
@@ -37,28 +41,32 @@ public class RegisterUserCommand : IRegisterUserCommand
private readonly IValidateRedemptionTokenCommand _validateRedemptionTokenCommand;
private readonly IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> _emergencyAccessInviteTokenDataFactory;
private readonly IFeatureService _featureService;
private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator.";
public RegisterUserCommand(
ILogger<RegisterUserCommand> logger,
IGlobalSettings globalSettings,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository,
IOrganizationDomainRepository organizationDomainRepository,
IFeatureService featureService,
IDataProtectionProvider dataProtectionProvider,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> registrationEmailVerificationTokenDataFactory,
IUserService userService,
IMailService mailService,
IValidateRedemptionTokenCommand validateRedemptionTokenCommand,
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> emergencyAccessInviteTokenDataFactory,
IFeatureService featureService)
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> emergencyAccessInviteTokenDataFactory)
{
_logger = logger;
_globalSettings = globalSettings;
_organizationUserRepository = organizationUserRepository;
_organizationRepository = organizationRepository;
_policyRepository = policyRepository;
_organizationDomainRepository = organizationDomainRepository;
_featureService = featureService;
_organizationServiceDataProtector = dataProtectionProvider.CreateProtector(
"OrganizationServiceDataProtector");
@@ -77,6 +85,8 @@ public class RegisterUserCommand : IRegisterUserCommand
public async Task<IdentityResult> RegisterUser(User user)
{
await ValidateEmailDomainNotBlockedAsync(user.Email);
var result = await _userService.CreateUserAsync(user);
if (result == IdentityResult.Success)
{
@@ -102,6 +112,11 @@ public class RegisterUserCommand : IRegisterUserCommand
{
TryValidateOrgInviteToken(orgInviteToken, orgUserId, user);
var orgUser = await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user);
if (orgUser == null && orgUserId.HasValue)
{
throw new BadRequestException("Invalid organization user invitation.");
}
await ValidateEmailDomainNotBlockedAsync(user.Email, orgUser?.OrganizationId);
user.ApiKey = CoreHelpers.SecureRandomString(30);
@@ -265,6 +280,8 @@ public class RegisterUserCommand : IRegisterUserCommand
string emailVerificationToken)
{
ValidateOpenRegistrationAllowed();
await ValidateEmailDomainNotBlockedAsync(user.Email);
var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email);
user.EmailVerified = true;
@@ -284,6 +301,7 @@ public class RegisterUserCommand : IRegisterUserCommand
string orgSponsoredFreeFamilyPlanInviteToken)
{
ValidateOpenRegistrationAllowed();
await ValidateEmailDomainNotBlockedAsync(user.Email);
await ValidateOrgSponsoredFreeFamilyPlanInviteToken(orgSponsoredFreeFamilyPlanInviteToken, user.Email);
user.EmailVerified = true;
@@ -304,6 +322,7 @@ public class RegisterUserCommand : IRegisterUserCommand
string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId)
{
ValidateOpenRegistrationAllowed();
await ValidateEmailDomainNotBlockedAsync(user.Email);
ValidateAcceptEmergencyAccessInviteToken(acceptEmergencyAccessInviteToken, acceptEmergencyAccessId, user.Email);
user.EmailVerified = true;
@@ -322,6 +341,7 @@ public class RegisterUserCommand : IRegisterUserCommand
string providerInviteToken, Guid providerUserId)
{
ValidateOpenRegistrationAllowed();
await ValidateEmailDomainNotBlockedAsync(user.Email);
ValidateProviderInviteToken(providerInviteToken, providerUserId, user.Email);
user.EmailVerified = true;
@@ -387,6 +407,28 @@ public class RegisterUserCommand : IRegisterUserCommand
return tokenable;
}
private async Task ValidateEmailDomainNotBlockedAsync(string email, Guid? excludeOrganizationId = null)
{
// Only check if feature flag is enabled
if (!_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation))
{
return;
}
var emailDomain = EmailValidation.GetDomain(email);
var isDomainBlocked = await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(
emailDomain, excludeOrganizationId);
if (isDomainBlocked)
{
_logger.LogInformation(
"User registration blocked by domain claim policy. Domain: {Domain}, ExcludedOrgId: {ExcludedOrgId}",
emailDomain,
excludeOrganizationId);
throw new BadRequestException("This email address is claimed by an organization using Bitwarden.");
}
}
/// <summary>
/// We send different welcome emails depending on whether the user is joining a free/family or an enterprise organization. If information to populate the
/// email isn't present we send the standard individual welcome email.

View File

@@ -5,6 +5,8 @@ using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Auth.UserFeatures.Registration.Implementations;
@@ -15,25 +17,30 @@ namespace Bit.Core.Auth.UserFeatures.Registration.Implementations;
/// </summary>
public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmailForRegistrationCommand
{
private readonly ILogger<SendVerificationEmailForRegistrationCommand> _logger;
private readonly IUserRepository _userRepository;
private readonly GlobalSettings _globalSettings;
private readonly IMailService _mailService;
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _tokenDataFactory;
private readonly IFeatureService _featureService;
private readonly IOrganizationDomainRepository _organizationDomainRepository;
public SendVerificationEmailForRegistrationCommand(
ILogger<SendVerificationEmailForRegistrationCommand> logger,
IUserRepository userRepository,
GlobalSettings globalSettings,
IMailService mailService,
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> tokenDataFactory,
IFeatureService featureService)
IFeatureService featureService,
IOrganizationDomainRepository organizationDomainRepository)
{
_logger = logger;
_userRepository = userRepository;
_globalSettings = globalSettings;
_mailService = mailService;
_tokenDataFactory = tokenDataFactory;
_featureService = featureService;
_organizationDomainRepository = organizationDomainRepository;
}
@@ -49,6 +56,20 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
throw new ArgumentNullException(nameof(email));
}
// Check if the email domain is blocked by an organization policy
if (_featureService.IsEnabled(FeatureFlagKeys.BlockClaimedDomainAccountCreation))
{
var emailDomain = EmailValidation.GetDomain(email);
if (await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(emailDomain))
{
_logger.LogInformation(
"User registration email verification blocked by domain claim policy. Domain: {Domain}",
emailDomain);
throw new BadRequestException("This email address is claimed by an organization using Bitwarden.");
}
}
// Check to see if the user already exists
var user = await _userRepository.GetByEmailAsync(email);
var userExists = user != null;

View File

@@ -0,0 +1,25 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models;
public class SponsoredPlans
{
public static IEnumerable<SponsoredPlan> All { get; set; } =
[
new()
{
PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise,
SponsoredProductTierType = ProductTierType.Families,
SponsoringProductTierType = ProductTierType.Enterprise,
StripePlanId = "2021-family-for-enterprise-annually",
UsersCanSponsor = org =>
org.PlanType.GetProductTier() == ProductTierType.Enterprise,
}
];
public static SponsoredPlan Get(PlanSponsorshipType planSponsorshipType) =>
All.FirstOrDefault(p => p.PlanSponsorshipType == planSponsorshipType)!;
}

View File

@@ -1,21 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record CustomPlan : Plan
{
public CustomPlan()
{
Type = PlanType.Custom;
PasswordManager = new CustomPasswordManagerFeatures();
}
private record CustomPasswordManagerFeatures : PasswordManagerPlanFeatures
{
public CustomPasswordManagerFeatures()
{
AllowSeatAutoscale = true;
}
}
}

View File

@@ -1,103 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record Enterprise2019Plan : Plan
{
public Enterprise2019Plan(bool isAnnual)
{
Type = isAnnual ? PlanType.EnterpriseAnnually2019 : PlanType.EnterpriseMonthly2019;
ProductTier = ProductTierType.Enterprise;
Name = isAnnual ? "Enterprise (Annually) 2019" : "Enterprise (Monthly) 2019";
IsAnnual = isAnnual;
NameLocalizationKey = "planNameEnterprise";
DescriptionLocalizationKey = "planDescEnterprise";
CanBeUsedByBusiness = true;
TrialPeriodDays = 7;
HasPolicies = true;
HasSelfHost = true;
HasGroups = true;
HasDirectory = true;
HasEvents = true;
HasTotp = true;
Has2fa = true;
HasApi = true;
HasSso = true;
HasOrganizationDomains = true;
HasKeyConnector = true;
HasScim = true;
HasResetPassword = true;
UsersGetPremium = true;
HasCustomPermissions = true;
UpgradeSortOrder = 4;
DisplaySortOrder = 4;
LegacyYear = 2020;
SecretsManager = new Enterprise2019SecretsManagerFeatures(isAnnual);
PasswordManager = new Enterprise2019PasswordManagerFeatures(isAnnual);
}
private record Enterprise2019SecretsManagerFeatures : SecretsManagerPlanFeatures
{
public Enterprise2019SecretsManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BasePrice = 0;
BaseServiceAccount = 200;
HasAdditionalSeatsOption = true;
HasAdditionalServiceAccountOption = true;
AllowSeatAutoscale = true;
AllowServiceAccountsAutoscale = true;
if (isAnnual)
{
StripeSeatPlanId = "secrets-manager-enterprise-seat-annually";
StripeServiceAccountPlanId = "secrets-manager-service-account-annually";
SeatPrice = 144;
AdditionalPricePerServiceAccount = 6;
}
else
{
StripeSeatPlanId = "secrets-manager-enterprise-seat-monthly";
StripeServiceAccountPlanId = "secrets-manager-service-account-monthly";
SeatPrice = 13;
AdditionalPricePerServiceAccount = 0.5M;
}
}
}
private record Enterprise2019PasswordManagerFeatures : PasswordManagerPlanFeatures
{
public Enterprise2019PasswordManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BaseStorageGb = 1;
HasAdditionalStorageOption = true;
HasAdditionalSeatsOption = true;
AllowSeatAutoscale = true;
if (isAnnual)
{
StripeStoragePlanId = "storage-gb-annually";
StripeSeatPlanId = "enterprise-org-seat-annually";
SeatPrice = 36;
AdditionalStoragePricePerGb = 4;
}
else
{
StripeSeatPlanId = "enterprise-org-seat-monthly";
StripeStoragePlanId = "storage-gb-monthly";
SeatPrice = 4M;
AdditionalStoragePricePerGb = 0.5M;
}
}
}
}

View File

@@ -1,103 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record Enterprise2020Plan : Plan
{
public Enterprise2020Plan(bool isAnnual)
{
Type = isAnnual ? PlanType.EnterpriseAnnually2020 : PlanType.EnterpriseMonthly2020;
ProductTier = ProductTierType.Enterprise;
Name = isAnnual ? "Enterprise (Annually) 2020" : "Enterprise (Monthly) 2020";
IsAnnual = isAnnual;
NameLocalizationKey = "planNameEnterprise";
DescriptionLocalizationKey = "planDescEnterprise";
CanBeUsedByBusiness = true;
TrialPeriodDays = 7;
HasPolicies = true;
HasSelfHost = true;
HasGroups = true;
HasDirectory = true;
HasEvents = true;
HasTotp = true;
Has2fa = true;
HasApi = true;
HasSso = true;
HasOrganizationDomains = true;
HasKeyConnector = true;
HasScim = true;
HasResetPassword = true;
UsersGetPremium = true;
HasCustomPermissions = true;
UpgradeSortOrder = 4;
DisplaySortOrder = 4;
LegacyYear = 2023;
PasswordManager = new Enterprise2020PasswordManagerFeatures(isAnnual);
SecretsManager = new Enterprise2020SecretsManagerFeatures(isAnnual);
}
private record Enterprise2020SecretsManagerFeatures : SecretsManagerPlanFeatures
{
public Enterprise2020SecretsManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BasePrice = 0;
BaseServiceAccount = 200;
HasAdditionalSeatsOption = true;
HasAdditionalServiceAccountOption = true;
AllowSeatAutoscale = true;
AllowServiceAccountsAutoscale = true;
if (isAnnual)
{
StripeSeatPlanId = "secrets-manager-enterprise-seat-annually";
StripeServiceAccountPlanId = "secrets-manager-service-account-annually";
SeatPrice = 144;
AdditionalPricePerServiceAccount = 6;
}
else
{
StripeSeatPlanId = "secrets-manager-enterprise-seat-monthly";
StripeServiceAccountPlanId = "secrets-manager-service-account-monthly";
SeatPrice = 13;
AdditionalPricePerServiceAccount = 0.5M;
}
}
}
private record Enterprise2020PasswordManagerFeatures : PasswordManagerPlanFeatures
{
public Enterprise2020PasswordManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BaseStorageGb = 1;
HasAdditionalStorageOption = true;
HasAdditionalSeatsOption = true;
AllowSeatAutoscale = true;
if (isAnnual)
{
AdditionalStoragePricePerGb = 4;
StripeStoragePlanId = "storage-gb-annually";
StripeSeatPlanId = "2020-enterprise-org-seat-annually";
SeatPrice = 60;
}
else
{
StripeSeatPlanId = "2020-enterprise-seat-monthly";
StripeStoragePlanId = "storage-gb-monthly";
SeatPrice = 6;
AdditionalStoragePricePerGb = 0.5M;
}
}
}
}

View File

@@ -1,106 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record EnterprisePlan : Plan
{
public EnterprisePlan(bool isAnnual)
{
Type = isAnnual ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly;
ProductTier = ProductTierType.Enterprise;
Name = isAnnual ? "Enterprise (Annually)" : "Enterprise (Monthly)";
IsAnnual = isAnnual;
NameLocalizationKey = "planNameEnterprise";
DescriptionLocalizationKey = "planDescEnterprise";
CanBeUsedByBusiness = true;
TrialPeriodDays = 7;
HasPolicies = true;
HasSelfHost = true;
HasGroups = true;
HasDirectory = true;
HasEvents = true;
HasTotp = true;
Has2fa = true;
HasApi = true;
HasSso = true;
HasOrganizationDomains = true;
HasKeyConnector = true;
HasScim = true;
HasResetPassword = true;
UsersGetPremium = true;
HasCustomPermissions = true;
UpgradeSortOrder = 4;
DisplaySortOrder = 4;
PasswordManager = new EnterprisePasswordManagerFeatures(isAnnual);
SecretsManager = new EnterpriseSecretsManagerFeatures(isAnnual);
}
private record EnterpriseSecretsManagerFeatures : SecretsManagerPlanFeatures
{
public EnterpriseSecretsManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BasePrice = 0;
BaseServiceAccount = 50;
HasAdditionalSeatsOption = true;
HasAdditionalServiceAccountOption = true;
AllowSeatAutoscale = true;
AllowServiceAccountsAutoscale = true;
if (isAnnual)
{
StripeSeatPlanId = "secrets-manager-enterprise-seat-annually";
StripeServiceAccountPlanId = "secrets-manager-service-account-2024-annually";
SeatPrice = 144;
AdditionalPricePerServiceAccount = 12;
}
else
{
StripeSeatPlanId = "secrets-manager-enterprise-seat-monthly";
StripeServiceAccountPlanId = "secrets-manager-service-account-2024-monthly";
SeatPrice = 13;
AdditionalPricePerServiceAccount = 1;
}
}
}
private record EnterprisePasswordManagerFeatures : PasswordManagerPlanFeatures
{
public EnterprisePasswordManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BaseStorageGb = 1;
HasAdditionalStorageOption = true;
HasAdditionalSeatsOption = true;
AllowSeatAutoscale = true;
if (isAnnual)
{
AdditionalStoragePricePerGb = 4;
StripeStoragePlanId = "storage-gb-annually";
StripeSeatPlanId = "2023-enterprise-org-seat-annually";
StripeProviderPortalSeatPlanId = "password-manager-provider-portal-enterprise-annually-2024";
SeatPrice = 72;
ProviderPortalSeatPrice = 72;
}
else
{
StripeSeatPlanId = "2023-enterprise-seat-monthly";
StripeProviderPortalSeatPlanId = "password-manager-provider-portal-enterprise-monthly-2024";
StripeStoragePlanId = "storage-gb-monthly";
SeatPrice = 7;
ProviderPortalSeatPrice = 6;
AdditionalStoragePricePerGb = 0.5M;
}
}
}
}

View File

@@ -1,104 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record Enterprise2023Plan : Plan
{
public Enterprise2023Plan(bool isAnnual)
{
Type = isAnnual ? PlanType.EnterpriseAnnually2023 : PlanType.EnterpriseMonthly2023;
ProductTier = ProductTierType.Enterprise;
Name = isAnnual ? "Enterprise (Annually)" : "Enterprise (Monthly)";
IsAnnual = isAnnual;
NameLocalizationKey = "planNameEnterprise";
DescriptionLocalizationKey = "planDescEnterprise";
CanBeUsedByBusiness = true;
TrialPeriodDays = 7;
HasPolicies = true;
HasSelfHost = true;
HasGroups = true;
HasDirectory = true;
HasEvents = true;
HasTotp = true;
Has2fa = true;
HasApi = true;
HasSso = true;
HasOrganizationDomains = true;
HasKeyConnector = true;
HasScim = true;
HasResetPassword = true;
UsersGetPremium = true;
HasCustomPermissions = true;
UpgradeSortOrder = 4;
DisplaySortOrder = 4;
LegacyYear = 2024;
PasswordManager = new Enterprise2023PasswordManagerFeatures(isAnnual);
SecretsManager = new Enterprise2023SecretsManagerFeatures(isAnnual);
}
private record Enterprise2023SecretsManagerFeatures : SecretsManagerPlanFeatures
{
public Enterprise2023SecretsManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BasePrice = 0;
BaseServiceAccount = 200;
HasAdditionalSeatsOption = true;
HasAdditionalServiceAccountOption = true;
AllowSeatAutoscale = true;
AllowServiceAccountsAutoscale = true;
if (isAnnual)
{
StripeSeatPlanId = "secrets-manager-enterprise-seat-annually";
StripeServiceAccountPlanId = "secrets-manager-service-account-annually";
SeatPrice = 144;
AdditionalPricePerServiceAccount = 6;
}
else
{
StripeSeatPlanId = "secrets-manager-enterprise-seat-monthly";
StripeServiceAccountPlanId = "secrets-manager-service-account-monthly";
SeatPrice = 13;
AdditionalPricePerServiceAccount = 0.5M;
}
}
}
private record Enterprise2023PasswordManagerFeatures : PasswordManagerPlanFeatures
{
public Enterprise2023PasswordManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BaseStorageGb = 1;
HasAdditionalStorageOption = true;
HasAdditionalSeatsOption = true;
AllowSeatAutoscale = true;
if (isAnnual)
{
AdditionalStoragePricePerGb = 4;
StripeStoragePlanId = "storage-gb-annually";
StripeSeatPlanId = "2023-enterprise-org-seat-annually";
SeatPrice = 72;
}
else
{
StripeSeatPlanId = "2023-enterprise-seat-monthly";
StripeStoragePlanId = "storage-gb-monthly";
SeatPrice = 7;
AdditionalStoragePricePerGb = 0.5M;
}
}
}
}

View File

@@ -1,50 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record Families2019Plan : Plan
{
public Families2019Plan()
{
Type = PlanType.FamiliesAnnually2019;
ProductTier = ProductTierType.Families;
Name = "Families 2019";
IsAnnual = true;
NameLocalizationKey = "planNameFamilies";
DescriptionLocalizationKey = "planDescFamilies";
TrialPeriodDays = 7;
HasSelfHost = true;
HasTotp = true;
UpgradeSortOrder = 1;
DisplaySortOrder = 1;
LegacyYear = 2020;
PasswordManager = new Families2019PasswordManagerFeatures();
}
private record Families2019PasswordManagerFeatures : PasswordManagerPlanFeatures
{
public Families2019PasswordManagerFeatures()
{
BaseSeats = 5;
BaseStorageGb = 1;
MaxSeats = 5;
HasAdditionalStorageOption = true;
HasPremiumAccessOption = true;
StripePlanId = "personal-org-annually";
StripeStoragePlanId = "personal-storage-gb-annually";
StripePremiumAccessPlanId = "personal-org-premium-access-annually";
BasePrice = 12;
AdditionalStoragePricePerGb = 4;
PremiumAccessOptionPrice = 40;
AllowSeatAutoscale = false;
}
}
}

View File

@@ -1,47 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record Families2025Plan : Plan
{
public Families2025Plan()
{
Type = PlanType.FamiliesAnnually2025;
ProductTier = ProductTierType.Families;
Name = "Families 2025";
IsAnnual = true;
NameLocalizationKey = "planNameFamilies";
DescriptionLocalizationKey = "planDescFamilies";
TrialPeriodDays = 7;
HasSelfHost = true;
HasTotp = true;
UsersGetPremium = true;
UpgradeSortOrder = 1;
DisplaySortOrder = 1;
PasswordManager = new Families2025PasswordManagerFeatures();
}
private record Families2025PasswordManagerFeatures : PasswordManagerPlanFeatures
{
public Families2025PasswordManagerFeatures()
{
BaseSeats = 6;
BaseStorageGb = 1;
MaxSeats = 6;
HasAdditionalStorageOption = true;
StripePlanId = "2020-families-org-annually";
StripeStoragePlanId = "personal-storage-gb-annually";
BasePrice = 40;
AdditionalStoragePricePerGb = 4;
AllowSeatAutoscale = false;
}
}
}

View File

@@ -1,47 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record FamiliesPlan : Plan
{
public FamiliesPlan()
{
Type = PlanType.FamiliesAnnually;
ProductTier = ProductTierType.Families;
Name = "Families";
IsAnnual = true;
NameLocalizationKey = "planNameFamilies";
DescriptionLocalizationKey = "planDescFamilies";
TrialPeriodDays = 7;
HasSelfHost = true;
HasTotp = true;
UsersGetPremium = true;
UpgradeSortOrder = 1;
DisplaySortOrder = 1;
PasswordManager = new FamiliesPasswordManagerFeatures();
}
private record FamiliesPasswordManagerFeatures : PasswordManagerPlanFeatures
{
public FamiliesPasswordManagerFeatures()
{
BaseSeats = 6;
BaseStorageGb = 1;
MaxSeats = 6;
HasAdditionalStorageOption = true;
StripePlanId = "2020-families-org-annually";
StripeStoragePlanId = "personal-storage-gb-annually";
BasePrice = 40;
AdditionalStoragePricePerGb = 4;
AllowSeatAutoscale = false;
}
}
}

View File

@@ -1,48 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record FreePlan : Plan
{
public FreePlan()
{
Type = PlanType.Free;
ProductTier = ProductTierType.Free;
Name = "Free";
NameLocalizationKey = "planNameFree";
DescriptionLocalizationKey = "planDescFree";
UpgradeSortOrder = -1; // Always the lowest plan, cannot be upgraded to
DisplaySortOrder = -1;
PasswordManager = new FreePasswordManagerFeatures();
SecretsManager = new FreeSecretsManagerFeatures();
}
private record FreeSecretsManagerFeatures : SecretsManagerPlanFeatures
{
public FreeSecretsManagerFeatures()
{
BaseSeats = 2;
BaseServiceAccount = 3;
MaxProjects = 3;
MaxSeats = 2;
MaxServiceAccounts = 3;
AllowSeatAutoscale = false;
}
}
private record FreePasswordManagerFeatures : PasswordManagerPlanFeatures
{
public FreePasswordManagerFeatures()
{
BaseSeats = 2;
MaxCollections = 2;
MaxSeats = 2;
AllowSeatAutoscale = false;
}
}
}

View File

@@ -1,99 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record Teams2019Plan : Plan
{
public Teams2019Plan(bool isAnnual)
{
Type = isAnnual ? PlanType.TeamsAnnually2019 : PlanType.TeamsMonthly2019;
ProductTier = ProductTierType.Teams;
Name = isAnnual ? "Teams (Annually) 2019" : "Teams (Monthly) 2019";
IsAnnual = isAnnual;
NameLocalizationKey = "planNameTeams";
DescriptionLocalizationKey = "planDescTeams";
CanBeUsedByBusiness = true;
TrialPeriodDays = 7;
HasGroups = true;
HasDirectory = true;
HasEvents = true;
HasTotp = true;
Has2fa = true;
HasApi = true;
UsersGetPremium = true;
UpgradeSortOrder = 3;
DisplaySortOrder = 3;
LegacyYear = 2020;
SecretsManager = new Teams2019SecretsManagerFeatures(isAnnual);
PasswordManager = new Teams2019PasswordManagerFeatures(isAnnual);
}
private record Teams2019SecretsManagerFeatures : SecretsManagerPlanFeatures
{
public Teams2019SecretsManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BasePrice = 0;
BaseServiceAccount = 50;
HasAdditionalSeatsOption = true;
HasAdditionalServiceAccountOption = true;
AllowSeatAutoscale = true;
AllowServiceAccountsAutoscale = true;
if (isAnnual)
{
StripeSeatPlanId = "secrets-manager-teams-seat-annually";
StripeServiceAccountPlanId = "secrets-manager-service-account-annually";
SeatPrice = 72;
AdditionalPricePerServiceAccount = 6;
}
else
{
StripeSeatPlanId = "secrets-manager-teams-seat-monthly";
StripeServiceAccountPlanId = "secrets-manager-service-account-monthly";
SeatPrice = 7;
AdditionalPricePerServiceAccount = 0.5M;
}
}
}
private record Teams2019PasswordManagerFeatures : PasswordManagerPlanFeatures
{
public Teams2019PasswordManagerFeatures(bool isAnnual)
{
BaseSeats = 5;
BaseStorageGb = 1;
HasAdditionalStorageOption = true;
HasAdditionalSeatsOption = true;
AllowSeatAutoscale = true;
if (isAnnual)
{
StripePlanId = "teams-org-annually";
StripeStoragePlanId = "storage-gb-annually";
StripeSeatPlanId = "teams-org-seat-annually";
SeatPrice = 24;
BasePrice = 60;
AdditionalStoragePricePerGb = 4;
}
else
{
StripePlanId = "teams-org-monthly";
StripeSeatPlanId = "teams-org-seat-monthly";
StripeStoragePlanId = "storage-gb-monthly";
BasePrice = 8;
SeatPrice = 2.5M;
AdditionalStoragePricePerGb = 0.5M;
}
}
}
}

View File

@@ -1,96 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record Teams2020Plan : Plan
{
public Teams2020Plan(bool isAnnual)
{
Type = isAnnual ? PlanType.TeamsAnnually2020 : PlanType.TeamsMonthly2020;
ProductTier = ProductTierType.Teams;
Name = isAnnual ? "Teams (Annually) 2020" : "Teams (Monthly) 2020";
IsAnnual = isAnnual;
NameLocalizationKey = "planNameTeams";
DescriptionLocalizationKey = "planDescTeams";
CanBeUsedByBusiness = true;
TrialPeriodDays = 7;
HasGroups = true;
HasDirectory = true;
HasEvents = true;
HasTotp = true;
Has2fa = true;
HasApi = true;
UsersGetPremium = true;
UpgradeSortOrder = 3;
DisplaySortOrder = 3;
LegacyYear = 2023;
PasswordManager = new Teams2020PasswordManagerFeatures(isAnnual);
SecretsManager = new Teams2020SecretsManagerFeatures(isAnnual);
}
private record Teams2020SecretsManagerFeatures : SecretsManagerPlanFeatures
{
public Teams2020SecretsManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BasePrice = 0;
BaseServiceAccount = 50;
HasAdditionalSeatsOption = true;
HasAdditionalServiceAccountOption = true;
AllowSeatAutoscale = true;
AllowServiceAccountsAutoscale = true;
if (isAnnual)
{
StripeSeatPlanId = "secrets-manager-teams-seat-annually";
StripeServiceAccountPlanId = "secrets-manager-service-account-annually";
SeatPrice = 72;
AdditionalPricePerServiceAccount = 6;
}
else
{
StripeSeatPlanId = "secrets-manager-teams-seat-monthly";
StripeServiceAccountPlanId = "secrets-manager-service-account-monthly";
SeatPrice = 7;
AdditionalPricePerServiceAccount = 0.5M;
}
}
}
private record Teams2020PasswordManagerFeatures : PasswordManagerPlanFeatures
{
public Teams2020PasswordManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BaseStorageGb = 1;
BasePrice = 0;
HasAdditionalStorageOption = true;
HasAdditionalSeatsOption = true;
AllowSeatAutoscale = true;
if (isAnnual)
{
StripeStoragePlanId = "storage-gb-annually";
StripeSeatPlanId = "2020-teams-org-seat-annually";
SeatPrice = 36;
AdditionalStoragePricePerGb = 4;
}
else
{
StripeSeatPlanId = "2020-teams-org-seat-monthly";
StripeStoragePlanId = "storage-gb-monthly";
SeatPrice = 4;
AdditionalStoragePricePerGb = 0.5M;
}
}
}
}

View File

@@ -1,98 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record TeamsPlan : Plan
{
public TeamsPlan(bool isAnnual)
{
Type = isAnnual ? PlanType.TeamsAnnually : PlanType.TeamsMonthly;
ProductTier = ProductTierType.Teams;
Name = isAnnual ? "Teams (Annually)" : "Teams (Monthly)";
IsAnnual = isAnnual;
NameLocalizationKey = "planNameTeams";
DescriptionLocalizationKey = "planDescTeams";
CanBeUsedByBusiness = true;
TrialPeriodDays = 7;
HasGroups = true;
HasDirectory = true;
HasEvents = true;
HasTotp = true;
Has2fa = true;
HasApi = true;
UsersGetPremium = true;
HasScim = true;
UpgradeSortOrder = 3;
DisplaySortOrder = 3;
PasswordManager = new TeamsPasswordManagerFeatures(isAnnual);
SecretsManager = new TeamsSecretsManagerFeatures(isAnnual);
}
private record TeamsSecretsManagerFeatures : SecretsManagerPlanFeatures
{
public TeamsSecretsManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BasePrice = 0;
BaseServiceAccount = 20;
HasAdditionalSeatsOption = true;
HasAdditionalServiceAccountOption = true;
AllowSeatAutoscale = true;
AllowServiceAccountsAutoscale = true;
if (isAnnual)
{
StripeSeatPlanId = "secrets-manager-teams-seat-annually";
StripeServiceAccountPlanId = "secrets-manager-service-account-2024-annually";
SeatPrice = 72;
AdditionalPricePerServiceAccount = 12;
}
else
{
StripeSeatPlanId = "secrets-manager-teams-seat-monthly";
StripeServiceAccountPlanId = "secrets-manager-service-account-2024-monthly";
SeatPrice = 7;
AdditionalPricePerServiceAccount = 1;
}
}
}
private record TeamsPasswordManagerFeatures : PasswordManagerPlanFeatures
{
public TeamsPasswordManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BaseStorageGb = 1;
BasePrice = 0;
HasAdditionalStorageOption = true;
HasAdditionalSeatsOption = true;
AllowSeatAutoscale = true;
if (isAnnual)
{
StripeStoragePlanId = "storage-gb-annually";
StripeSeatPlanId = "2023-teams-org-seat-annually";
SeatPrice = 48;
AdditionalStoragePricePerGb = 4;
}
else
{
StripeSeatPlanId = "2023-teams-org-seat-monthly";
StripeProviderPortalSeatPlanId = "password-manager-provider-portal-teams-monthly-2024";
StripeStoragePlanId = "storage-gb-monthly";
SeatPrice = 5;
ProviderPortalSeatPrice = 4;
AdditionalStoragePricePerGb = 0.5M;
}
}
}
}

View File

@@ -1,97 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record Teams2023Plan : Plan
{
public Teams2023Plan(bool isAnnual)
{
Type = isAnnual ? PlanType.TeamsAnnually2023 : PlanType.TeamsMonthly2023;
ProductTier = ProductTierType.Teams;
Name = isAnnual ? "Teams (Annually)" : "Teams (Monthly)";
IsAnnual = isAnnual;
NameLocalizationKey = "planNameTeams";
DescriptionLocalizationKey = "planDescTeams";
CanBeUsedByBusiness = true;
TrialPeriodDays = 7;
HasGroups = true;
HasDirectory = true;
HasEvents = true;
HasTotp = true;
Has2fa = true;
HasApi = true;
UsersGetPremium = true;
UpgradeSortOrder = 3;
DisplaySortOrder = 3;
LegacyYear = 2024;
PasswordManager = new Teams2023PasswordManagerFeatures(isAnnual);
SecretsManager = new Teams2023SecretsManagerFeatures(isAnnual);
}
private record Teams2023SecretsManagerFeatures : SecretsManagerPlanFeatures
{
public Teams2023SecretsManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BasePrice = 0;
BaseServiceAccount = 50;
HasAdditionalSeatsOption = true;
HasAdditionalServiceAccountOption = true;
AllowSeatAutoscale = true;
AllowServiceAccountsAutoscale = true;
if (isAnnual)
{
StripeSeatPlanId = "secrets-manager-teams-seat-annually";
StripeServiceAccountPlanId = "secrets-manager-service-account-annually";
SeatPrice = 72;
AdditionalPricePerServiceAccount = 6;
}
else
{
StripeSeatPlanId = "secrets-manager-teams-seat-monthly";
StripeServiceAccountPlanId = "secrets-manager-service-account-monthly";
SeatPrice = 7;
AdditionalPricePerServiceAccount = 0.5M;
}
}
}
private record Teams2023PasswordManagerFeatures : PasswordManagerPlanFeatures
{
public Teams2023PasswordManagerFeatures(bool isAnnual)
{
BaseSeats = 0;
BaseStorageGb = 1;
BasePrice = 0;
HasAdditionalStorageOption = true;
HasAdditionalSeatsOption = true;
AllowSeatAutoscale = true;
if (isAnnual)
{
StripeStoragePlanId = "storage-gb-annually";
StripeSeatPlanId = "2023-teams-org-seat-annually";
SeatPrice = 48;
AdditionalStoragePricePerGb = 4;
}
else
{
StripeSeatPlanId = "2023-teams-org-seat-monthly";
StripeStoragePlanId = "storage-gb-monthly";
SeatPrice = 5;
AdditionalStoragePricePerGb = 0.5M;
}
}
}
}

View File

@@ -1,74 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record TeamsStarterPlan : Plan
{
public TeamsStarterPlan()
{
Type = PlanType.TeamsStarter;
ProductTier = ProductTierType.TeamsStarter;
Name = "Teams (Starter)";
NameLocalizationKey = "planNameTeamsStarter";
DescriptionLocalizationKey = "planDescTeams";
CanBeUsedByBusiness = true;
TrialPeriodDays = 7;
HasGroups = true;
HasDirectory = true;
HasEvents = true;
HasTotp = true;
Has2fa = true;
HasApi = true;
UsersGetPremium = true;
UpgradeSortOrder = 2;
DisplaySortOrder = 2;
PasswordManager = new TeamsStarterPasswordManagerFeatures();
SecretsManager = new TeamsStarterSecretsManagerFeatures();
LegacyYear = 2024;
}
private record TeamsStarterSecretsManagerFeatures : SecretsManagerPlanFeatures
{
public TeamsStarterSecretsManagerFeatures()
{
BaseSeats = 0;
BasePrice = 0;
BaseServiceAccount = 20;
HasAdditionalSeatsOption = true;
HasAdditionalServiceAccountOption = true;
AllowSeatAutoscale = true;
AllowServiceAccountsAutoscale = true;
StripeSeatPlanId = "secrets-manager-teams-seat-monthly";
StripeServiceAccountPlanId = "secrets-manager-service-account-2024-monthly";
SeatPrice = 7;
AdditionalPricePerServiceAccount = 1;
}
}
private record TeamsStarterPasswordManagerFeatures : PasswordManagerPlanFeatures
{
public TeamsStarterPasswordManagerFeatures()
{
BaseSeats = 10;
BaseStorageGb = 1;
BasePrice = 20;
MaxSeats = 10;
HasAdditionalStorageOption = true;
StripePlanId = "teams-org-starter";
StripeStoragePlanId = "storage-gb-monthly";
AdditionalStoragePricePerGb = 0.5M;
}
}
}

View File

@@ -1,73 +0,0 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.StaticStore.Plans;
public record TeamsStarterPlan2023 : Plan
{
public TeamsStarterPlan2023()
{
Type = PlanType.TeamsStarter2023;
ProductTier = ProductTierType.TeamsStarter;
Name = "Teams (Starter)";
NameLocalizationKey = "planNameTeamsStarter";
DescriptionLocalizationKey = "planDescTeams";
CanBeUsedByBusiness = true;
TrialPeriodDays = 7;
HasGroups = true;
HasDirectory = true;
HasEvents = true;
HasTotp = true;
Has2fa = true;
HasApi = true;
UsersGetPremium = true;
UpgradeSortOrder = 2;
DisplaySortOrder = 2;
PasswordManager = new TeamsStarter2023PasswordManagerFeatures();
SecretsManager = new TeamsStarter2023SecretsManagerFeatures();
LegacyYear = 2024;
}
private record TeamsStarter2023SecretsManagerFeatures : SecretsManagerPlanFeatures
{
public TeamsStarter2023SecretsManagerFeatures()
{
BaseSeats = 0;
BasePrice = 0;
BaseServiceAccount = 50;
HasAdditionalSeatsOption = true;
HasAdditionalServiceAccountOption = true;
AllowSeatAutoscale = true;
AllowServiceAccountsAutoscale = true;
StripeSeatPlanId = "secrets-manager-teams-seat-monthly";
StripeServiceAccountPlanId = "secrets-manager-service-account-monthly";
SeatPrice = 7;
AdditionalPricePerServiceAccount = 0.5M;
}
}
private record TeamsStarter2023PasswordManagerFeatures : PasswordManagerPlanFeatures
{
public TeamsStarter2023PasswordManagerFeatures()
{
BaseSeats = 10;
BaseStorageGb = 1;
BasePrice = 20;
MaxSeats = 10;
HasAdditionalStorageOption = true;
StripePlanId = "teams-org-starter";
StripeStoragePlanId = "storage-gb-monthly";
AdditionalStoragePricePerGb = 0.5M;
}
}
}

View File

@@ -3,12 +3,12 @@ using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using OneOf;
using Stripe;
@@ -54,7 +54,7 @@ public class PreviewOrganizationTaxCommand(
switch (purchase)
{
case { PasswordManager.Sponsored: true }:
var sponsoredPlan = StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise);
var sponsoredPlan = SponsoredPlans.Get(PlanSponsorshipType.FamiliesForEnterprise);
items.Add(new InvoiceSubscriptionDetailsItemOptions
{
Price = sponsoredPlan.StripePlanId,

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Billing.Models;
using Bit.Core.Models.Business;
using Stripe;
@@ -17,7 +18,7 @@ public class SponsorOrganizationSubscriptionUpdate : SubscriptionUpdate
{
_existingPlanStripeId = existingPlan.PasswordManager.StripePlanId;
_sponsoredPlanStripeId = sponsoredPlan?.StripePlanId
?? Core.Utilities.StaticStore.SponsoredPlans.FirstOrDefault()?.StripePlanId;
?? SponsoredPlans.All.FirstOrDefault()?.StripePlanId;
_applySponsorship = applySponsorship;
}

View File

@@ -6,7 +6,6 @@ using Bit.Core.Billing.Pricing.Organizations;
using Bit.Core.Exceptions;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Billing.Pricing;
@@ -28,13 +27,6 @@ public class PricingClient(
return null;
}
var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService);
if (!usePricingService)
{
return StaticStore.GetPlan(planType);
}
var lookupKey = GetLookupKey(planType);
if (lookupKey == null)
@@ -77,13 +69,6 @@ public class PricingClient(
return [];
}
var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService);
if (!usePricingService)
{
return StaticStore.Plans.ToList();
}
var response = await httpClient.GetAsync("plans/organization");
if (response.IsSuccessStatusCode)
@@ -114,11 +99,10 @@ public class PricingClient(
return [];
}
var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService);
var fetchPremiumPriceFromPricingService =
featureService.IsEnabled(FeatureFlagKeys.PM26793_FetchPremiumPriceFromPricingService);
if (!usePricingService || !fetchPremiumPriceFromPricingService)
if (!fetchPremiumPriceFromPricingService)
{
return [CurrentPremiumPlan];
}

View File

@@ -141,6 +141,7 @@ public static class FeatureFlagKeys
public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users";
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string AccountRecoveryCommand = "pm-25581-prevent-provider-account-recovery";
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects";
/* Architecture */
@@ -185,7 +186,6 @@ public static class FeatureFlagKeys
/* Billing Team */
public const string TrialPayment = "PM-8163-trial-payment";
public const string UsePricingService = "use-pricing-service";
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover";
public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings";
@@ -243,7 +243,6 @@ public static class FeatureFlagKeys
/* Vault Team */
public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge";
public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form";
public const string CipherKeyEncryption = "cipher-key-encryption";
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
public const string EndUserNotifications = "pm-10609-end-user-notifications";

View File

@@ -1,4 +1,5 @@
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Stripe;
#nullable enable
@@ -150,7 +151,7 @@ public class SubscriptionInfo
}
Quantity = (int)item.Quantity;
SponsoredSubscriptionItem = item.Plan != null && Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id);
SponsoredSubscriptionItem = item.Plan != null && SponsoredPlans.All.Any(p => p.StripePlanId == item.Plan.Id);
}
public bool AddonSubscriptionItem { get; set; }

View File

@@ -14,6 +14,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
@@ -135,6 +136,8 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>();
services.AddScoped<IConfirmOrganizationUserCommand, ConfirmOrganizationUserCommand>();
services.AddScoped<IAdminRecoverAccountCommand, AdminRecoverAccountCommand>();
services.AddScoped<IAutomaticallyConfirmOrganizationUserCommand, AutomaticallyConfirmOrganizationUserCommand>();
services.AddScoped<IAutomaticallyConfirmOrganizationUsersValidator, AutomaticallyConfirmOrganizationUsersValidator>();
services.AddScoped<IDeleteClaimedOrganizationUserAccountCommand, DeleteClaimedOrganizationUserAccountCommand>();
services.AddScoped<IDeleteClaimedOrganizationUserAccountValidator, DeleteClaimedOrganizationUserAccountValidator>();

View File

@@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -7,7 +8,6 @@ using Bit.Core.Models.Data.Organizations.OrganizationSponsorships;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Cloud;
@@ -54,10 +54,9 @@ public class CloudSyncSponsorshipsCommand : ICloudSyncSponsorshipsCommand
foreach (var selfHostedSponsorship in sponsorshipsData)
{
var requiredSponsoringProductType = StaticStore.GetSponsoredPlan(selfHostedSponsorship.PlanSponsorshipType)?.SponsoringProductTierType;
var requiredSponsoringProductType = SponsoredPlans.Get(selfHostedSponsorship.PlanSponsorshipType).SponsoringProductTierType;
var sponsoringOrgProductTier = sponsoringOrg.PlanType.GetProductTier();
if (requiredSponsoringProductType == null
|| sponsoringOrgProductTier != requiredSponsoringProductType.Value)
if (sponsoringOrgProductTier != requiredSponsoringProductType)
{
continue; // prevent unsupported sponsorships
}

View File

@@ -1,11 +1,11 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Cloud;
@@ -50,11 +50,10 @@ public class SetUpSponsorshipCommand : ISetUpSponsorshipCommand
}
// Check org to sponsor's product type
var requiredSponsoredProductType = StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value)?.SponsoredProductTierType;
var requiredSponsoredProductType = SponsoredPlans.Get(sponsorship.PlanSponsorshipType.Value).SponsoredProductTierType;
var sponsoredOrganizationProductTier = sponsoredOrganization.PlanType.GetProductTier();
if (requiredSponsoredProductType == null ||
sponsoredOrganizationProductTier != requiredSponsoredProductType.Value)
if (sponsoredOrganizationProductTier != requiredSponsoredProductType)
{
throw new BadRequestException("Can only redeem sponsorship offer on families organizations.");
}

View File

@@ -3,6 +3,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Entities;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
@@ -95,7 +96,7 @@ public class ValidateSponsorshipCommand : CancelSponsorshipCommand, IValidateSpo
return false;
}
var sponsoredPlan = Utilities.StaticStore.GetSponsoredPlan(existingSponsorship.PlanSponsorshipType.Value);
var sponsoredPlan = SponsoredPlans.Get(existingSponsorship.PlanSponsorshipType.Value);
var sponsoringOrganization = await _organizationRepository
.GetByIdAsync(existingSponsorship.SponsoringOrganizationId.Value);

View File

@@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -7,7 +8,6 @@ using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise;
@@ -34,11 +34,10 @@ public class CreateSponsorshipCommand(
throw new BadRequestException("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email.");
}
var requiredSponsoringProductType = StaticStore.GetSponsoredPlan(sponsorshipType)?.SponsoringProductTierType;
var requiredSponsoringProductType = SponsoredPlans.Get(sponsorshipType).SponsoringProductTierType;
var sponsoringOrgProductTier = sponsoringOrganization.PlanType.GetProductTier();
if (requiredSponsoringProductType == null ||
sponsoringOrgProductTier != requiredSponsoringProductType.Value)
if (sponsoringOrgProductTier != requiredSponsoringProductType)
{
throw new BadRequestException("Specified Organization cannot sponsor other organizations.");
}

View File

@@ -17,4 +17,5 @@ public interface IOrganizationDomainRepository : IRepository<OrganizationDomain,
Task<OrganizationDomain?> GetDomainByOrgIdAndDomainNameAsync(Guid orgId, string domainName);
Task<ICollection<OrganizationDomain>> GetExpiredOrganizationDomainsAsync();
Task<bool> DeleteExpiredAsync(int expirationPeriod);
Task<bool> HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(string domainName, Guid? excludeOrganizationId = null);
}

View File

@@ -67,7 +67,7 @@ public class StripePaymentService : IPaymentService
{
var existingPlan = await _pricingClient.GetPlanOrThrow(org.PlanType);
var sponsoredPlan = sponsorship?.PlanSponsorshipType != null
? Utilities.StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value)
? SponsoredPlans.Get(sponsorship.PlanSponsorshipType.Value)
: null;
var subscriptionUpdate =
new SponsorOrganizationSubscriptionUpdate(existingPlan, sponsoredPlan, applySponsorship);
@@ -1072,7 +1072,7 @@ public class StripePaymentService : IPaymentService
if (isSponsored)
{
var sponsoredPlan = Utilities.StaticStore.GetSponsoredPlan(parameters.PasswordManager.SponsoredPlan.Value);
var sponsoredPlan = SponsoredPlans.Get(parameters.PasswordManager.SponsoredPlan.Value);
options.SubscriptionDetails.Items.Add(
new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = sponsoredPlan.StripePlanId }
);

View File

@@ -21,7 +21,7 @@ public class GlobalSettings : IGlobalSettings
}
public bool SelfHosted { get; set; }
public bool UnifiedDeployment { get; set; }
public bool LiteDeployment { get; set; }
public virtual string KnownProxies { get; set; }
public virtual string SiteName { get; set; }
public virtual string ProjectName { get; set; }
@@ -783,7 +783,18 @@ public class GlobalSettings : IGlobalSettings
{
public virtual IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings();
public virtual IConnectionStringSettings Cosmos { get; set; } = new ConnectionStringSettings();
public ExtendedCacheSettings DefaultExtendedCache { get; set; } = new ExtendedCacheSettings();
}
/// <summary>
/// A collection of Settings for customizing the FusionCache used in extended caching. Defaults are
/// provided for every attribute so that only specific values need to be overridden if needed.
/// </summary>
public class ExtendedCacheSettings
{
public bool EnableDistributedCache { get; set; } = true;
public bool UseSharedRedisCache { get; set; } = true;
public IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings();
public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(30);
public bool IsFailSafeEnabled { get; set; } = true;
public TimeSpan FailSafeMaxDuration { get; set; } = TimeSpan.FromHours(2);

View File

@@ -6,7 +6,7 @@ public interface IGlobalSettings
{
// This interface exists for testing. Add settings here as needed for testing
bool SelfHosted { get; set; }
bool UnifiedDeployment { get; set; }
bool LiteDeployment { get; set; }
string KnownProxies { get; set; }
string ProjectName { get; set; }
bool EnableCloudCommunication { get; set; }

View File

@@ -1,4 +1,6 @@
using System.Text.RegularExpressions;
using System.Net.Mail;
using System.Text.RegularExpressions;
using Bit.Core.Exceptions;
using MimeKit;
namespace Bit.Core.Utilities;
@@ -41,4 +43,22 @@ public static class EmailValidation
return true;
}
/// <summary>
/// Extracts the domain portion from an email address and normalizes it to lowercase.
/// </summary>
/// <param name="email">The email address to extract the domain from.</param>
/// <returns>The domain portion of the email address in lowercase (e.g., "example.com").</returns>
/// <exception cref="BadRequestException">Thrown when the email address format is invalid.</exception>
public static string GetDomain(string email)
{
try
{
return new MailAddress(email).Host.ToLower();
}
catch (Exception ex) when (ex is FormatException || ex is ArgumentException)
{
throw new BadRequestException("Invalid email address format.");
}
}
}

View File

@@ -3,6 +3,7 @@ using Bit.Core.Utilities;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.Backplane;
@@ -14,77 +15,149 @@ namespace Microsoft.Extensions.DependencyInjection;
public static class ExtendedCacheServiceCollectionExtensions
{
/// <summary>
/// Add Fusion Cache <see href="https://github.com/ZiggyCreatures/FusionCache"/> to the service
/// collection.<br/>
/// Adds a new, named Fusion Cache <see href="https://github.com/ZiggyCreatures/FusionCache"/> to the service
/// collection. If an existing cache of the same name is found, it will do nothing.<br/>
/// <br/>
/// If Redis is configured, it uses Redis for an L2 cache and backplane. If not, it simply uses in-memory caching.
/// <b>Note</b>: When re-using the existing Redis cache, it is expected to call this method <b>after</b> calling
/// <code>services.AddDistributedCache(globalSettings)</code><br />This ensures that DI correctly finds,
/// configures, and re-uses all the shared Redis architecture.
/// </summary>
public static IServiceCollection TryAddExtendedCacheServices(this IServiceCollection services, GlobalSettings globalSettings)
public static IServiceCollection AddExtendedCache(
this IServiceCollection services,
string cacheName,
GlobalSettings globalSettings,
GlobalSettings.ExtendedCacheSettings? settings = null)
{
if (services.Any(s => s.ServiceType == typeof(IFusionCache)))
settings ??= globalSettings.DistributedCache.DefaultExtendedCache;
if (settings is null || string.IsNullOrEmpty(cacheName))
{
return services;
}
var fusionCacheBuilder = services.AddFusionCache()
.WithOptions(options =>
// If a cache already exists with this key, do nothing
if (services.Any(s => s.ServiceType == typeof(IFusionCache) &&
s.ServiceKey?.Equals(cacheName) == true))
{
return services;
}
if (services.All(s => s.ServiceType != typeof(FusionCacheSystemTextJsonSerializer)))
{
services.AddFusionCacheSystemTextJsonSerializer();
}
var fusionCacheBuilder = services
.AddFusionCache(cacheName)
.WithCacheKeyPrefix($"{cacheName}:")
.AsKeyedServiceByCacheName()
.WithOptions(opt =>
{
options.DistributedCacheCircuitBreakerDuration = globalSettings.DistributedCache.DistributedCacheCircuitBreakerDuration;
opt.DistributedCacheCircuitBreakerDuration = settings.DistributedCacheCircuitBreakerDuration;
})
.WithDefaultEntryOptions(new FusionCacheEntryOptions
{
Duration = globalSettings.DistributedCache.Duration,
IsFailSafeEnabled = globalSettings.DistributedCache.IsFailSafeEnabled,
FailSafeMaxDuration = globalSettings.DistributedCache.FailSafeMaxDuration,
FailSafeThrottleDuration = globalSettings.DistributedCache.FailSafeThrottleDuration,
EagerRefreshThreshold = globalSettings.DistributedCache.EagerRefreshThreshold,
FactorySoftTimeout = globalSettings.DistributedCache.FactorySoftTimeout,
FactoryHardTimeout = globalSettings.DistributedCache.FactoryHardTimeout,
DistributedCacheSoftTimeout = globalSettings.DistributedCache.DistributedCacheSoftTimeout,
DistributedCacheHardTimeout = globalSettings.DistributedCache.DistributedCacheHardTimeout,
AllowBackgroundDistributedCacheOperations = globalSettings.DistributedCache.AllowBackgroundDistributedCacheOperations,
JitterMaxDuration = globalSettings.DistributedCache.JitterMaxDuration
Duration = settings.Duration,
IsFailSafeEnabled = settings.IsFailSafeEnabled,
FailSafeMaxDuration = settings.FailSafeMaxDuration,
FailSafeThrottleDuration = settings.FailSafeThrottleDuration,
EagerRefreshThreshold = settings.EagerRefreshThreshold,
FactorySoftTimeout = settings.FactorySoftTimeout,
FactoryHardTimeout = settings.FactoryHardTimeout,
DistributedCacheSoftTimeout = settings.DistributedCacheSoftTimeout,
DistributedCacheHardTimeout = settings.DistributedCacheHardTimeout,
AllowBackgroundDistributedCacheOperations = settings.AllowBackgroundDistributedCacheOperations,
JitterMaxDuration = settings.JitterMaxDuration
})
.WithSerializer(
new FusionCacheSystemTextJsonSerializer()
);
.WithRegisteredSerializer();
if (!CoreHelpers.SettingHasValue(globalSettings.DistributedCache.Redis.ConnectionString))
{
if (!settings.EnableDistributedCache)
return services;
}
services.TryAddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect(globalSettings.DistributedCache.Redis.ConnectionString));
if (settings.UseSharedRedisCache)
{
// Using Shared Redis, TryAdd and reuse all pieces (multiplexer, distributed cache and backplane)
fusionCacheBuilder
.WithDistributedCache(sp =>
if (!CoreHelpers.SettingHasValue(globalSettings.DistributedCache.Redis.ConnectionString))
return services;
services.TryAddSingleton<IConnectionMultiplexer>(sp =>
CreateConnectionMultiplexer(sp, cacheName, globalSettings.DistributedCache.Redis.ConnectionString));
services.TryAddSingleton<IDistributedCache>(sp =>
{
var cache = sp.GetService<IDistributedCache>();
if (cache is not null)
{
return cache;
}
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
return new RedisCache(new RedisCacheOptions
{
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
});
})
.WithBackplane(sp =>
{
var backplane = sp.GetService<IFusionCacheBackplane>();
if (backplane is not null)
});
services.TryAddSingleton<IFusionCacheBackplane>(sp =>
{
return backplane;
}
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
return new RedisBackplane(new RedisBackplaneOptions
{
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
});
});
fusionCacheBuilder
.WithRegisteredDistributedCache()
.WithRegisteredBackplane();
return services;
}
// Using keyed Redis / Distributed Cache. Create all pieces as keyed services.
if (!CoreHelpers.SettingHasValue(settings.Redis.ConnectionString))
return services;
services.TryAddKeyedSingleton<IConnectionMultiplexer>(
cacheName,
(sp, _) => CreateConnectionMultiplexer(sp, cacheName, settings.Redis.ConnectionString)
);
services.TryAddKeyedSingleton<IDistributedCache>(
cacheName,
(sp, _) =>
{
var mux = sp.GetRequiredKeyedService<IConnectionMultiplexer>(cacheName);
return new RedisCache(new RedisCacheOptions
{
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
});
}
);
services.TryAddKeyedSingleton<IFusionCacheBackplane>(
cacheName,
(sp, _) =>
{
var mux = sp.GetRequiredKeyedService<IConnectionMultiplexer>(cacheName);
return new RedisBackplane(new RedisBackplaneOptions
{
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
});
});
}
);
fusionCacheBuilder
.WithRegisteredKeyedDistributedCacheByCacheName()
.WithRegisteredKeyedBackplaneByCacheName();
return services;
}
private static ConnectionMultiplexer CreateConnectionMultiplexer(IServiceProvider sp, string cacheName,
string connectionString)
{
try
{
return ConnectionMultiplexer.Connect(connectionString);
}
catch (Exception ex)
{
var logger = sp.GetService<ILogger>();
logger?.LogError(ex, "Failed to connect to Redis for cache {CacheName}", cacheName);
throw;
}
}
}

View File

@@ -0,0 +1,157 @@
## Extended Cache
`ExtendedCache` is a wrapper around [FusionCache](https://github.com/ZiggyCreatures/FusionCache)
that provides a simple way to register **named, isolated caches** with sensible defaults.
The goal is to make it trivial for each subsystem or feature to have its own cache -
with optional distributed caching and backplane support - without repeatedly wiring up
FusionCache, Redis, and related infrastructure.
Each named cache automatically receives:
- Its own `FusionCache` instance
- Its own configuration (default or overridden)
- Its own key prefix
- Optional distributed store
- Optional backplane
`ExtendedCache` supports several deployment modes:
- **Memory-only caching** (with stampede protection)
- **Memory + distributed cache + backplane** using the **shared** application Redis
- **Memory + distributed cache + backplane** using a **fully isolated** Redis instance
**Note**: When using the shared Redis cache option (which is on by default, if the
Redis connection string is configured), it is expected to call
`services.AddDistributedCache(globalSettings)` **before** calling
`AddExtendedCache`. The idea is to set up the distributed cache in our normal pattern
and then "extend" it to include more functionality.
### Configuration
`ExtendedCache` exposes a set of default properties that define how each named cache behaves.
These map directly to FusionCache configuration options such as timeouts, duration,
jitter, fail-safe mode, etc. Any cache can override these defaults independently.
#### Default configuration
The simplest approach registers a new named cache with default settings and reusing
the existing distributed cache:
``` csharp
services.AddDistributedCache(globalSettings);
services.AddExtendedCache(cacheName, globalSettings);
```
By default:
- If `GlobalSettings.DistributedCache.Redis.ConnectionString` is configured:
- The cache is memory + distributed (Redis)
- The Redis cache created by `AddDistributedCache` is re-used
- A Redis backplane is configured, re-using the same multiplexer
- If Redis is **not** configured the cache automatically falls back to memory-only
#### Overriding default properties
A number of default properties are provided (see
`GlobalSettings.DistributedCache.DefaultExtendedCache` for specific values). A named
cache can override any (or all) of these properties simply by providing its own
instance of `ExtendedCacheSettings`:
``` csharp
services.AddExtendedCache(cacheName, globalSettings, new GlobalSettings.ExtendedCacheSettings
{
Duration = TimeSpan.FromHours(1),
});
```
This example keeps all other defaults—including shared Redis—but changes the
default cached item duration from 30 minutes to 1 hour.
#### Isolated Redis configuration
ExtendedCache can also run in a fully isolated mode where the cache uses its own:
- Redis multiplexer
- Distributed cache
- Backplane
To enable this, specify a Redis connection string and set `UseSharedRedisCache`
to `false`:
``` csharp
services.AddExtendedCache(cacheName, globalSettings, new GlobalSettings.ExtendedCacheSettings
{
UseSharedRedisCache = false,
Redis = new GlobalSettings.ConnectionStringSettings { ConnectionString = "localhost:6379" }
});
```
When configured this way:
- A dedicated `IConnectionMultiplexer` is created
- A dedicated `IDistributedCache` is created
- A dedicated FusionCache backplane is created
- All three are exposed to DI as keyed services (using the cache name as service key)
### Accessing a named cache
A named cache can be retrieved either:
- Directly via DI using keyed services
- Through `IFusionCacheProvider` (similar to
[IHttpClientFactory](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/http-requests?view=aspnetcore-7.0#named-clients))
#### Keyed service
In the consuming class, declare an IFusionCache field:
```csharp
private IFusionCache _cache;
```
Then ask DI to inject the keyed cache:
```csharp
public MyService([FromKeyedServices("MyCache")] IFusionCache cache)
{
_cache = cache;
}
```
Or request it manually:
```csharp
cache: provider.GetRequiredKeyedService<IFusionCache>(serviceKey: cacheName)
```
#### Injecting a provider
Alternatively, an `IFusionCacheProvider` can be injected and used to request a named
cache - similar to how `IHttpClientFactory` can be used to create named `HttpClient`
instances
In the class using the cache, use an injected provider to request the named cache:
```csharp
private readonly IFusionCache _cache;
public MyController(IFusionCacheProvider cacheProvider)
{
_cache = cacheProvider.GetCache("CacheName");
}
```
### Using a cache
Using the cache in code is as simple as replacing the direct repository calls with
`FusionCache`'s `GetOrSet` call. If the class previously fetched an `Item` from
an `ItemRepository`, all that we need to do is provide a key and the original
repository call as the fallback:
```csharp
var item = _cache.GetOrSet<Item>(
$"item:{id}",
_ => _itemRepository.GetById(id)
);
```
`ExtendedCache` doesnt change how `FusionCache` is used in code, which means all
the functionality and full `FusionCache` API is available. See the
[FusionCache docs](https://github.com/ZiggyCreatures/FusionCache/blob/main/docs/CoreMethods.md)
for more details.

View File

@@ -1,13 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Collections.Immutable;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Utilities;
@@ -110,56 +104,7 @@ public static class StaticStore
GlobalDomains.Add(GlobalEquivalentDomainsType.Atlassian, new List<string> { "atlassian.com", "bitbucket.org", "trello.com", "statuspage.io", "atlassian.net", "jira.com" });
GlobalDomains.Add(GlobalEquivalentDomainsType.Pinterest, new List<string> { "pinterest.com", "pinterest.com.au", "pinterest.cl", "pinterest.de", "pinterest.dk", "pinterest.es", "pinterest.fr", "pinterest.co.uk", "pinterest.jp", "pinterest.co.kr", "pinterest.nz", "pinterest.pt", "pinterest.se" });
#endregion
Plans = new List<Plan>
{
new EnterprisePlan(true),
new EnterprisePlan(false),
new TeamsStarterPlan(),
new TeamsPlan(true),
new TeamsPlan(false),
new Enterprise2023Plan(true),
new Enterprise2023Plan(false),
new Enterprise2020Plan(true),
new Enterprise2020Plan(false),
new TeamsStarterPlan2023(),
new Teams2023Plan(true),
new Teams2023Plan(false),
new Teams2020Plan(true),
new Teams2020Plan(false),
new FamiliesPlan(),
new FreePlan(),
new CustomPlan(),
new Enterprise2019Plan(true),
new Enterprise2019Plan(false),
new Teams2019Plan(true),
new Teams2019Plan(false),
new Families2019Plan(),
new Families2025Plan()
}.ToImmutableList();
}
public static IDictionary<GlobalEquivalentDomainsType, IEnumerable<string>> GlobalDomains { get; set; }
[Obsolete("Use PricingClient.ListPlans to retrieve all plans.")]
public static IEnumerable<Plan> Plans { get; }
public static IEnumerable<SponsoredPlan> SponsoredPlans { get; set; } = new[]
{
new SponsoredPlan
{
PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise,
SponsoredProductTierType = ProductTierType.Families,
SponsoringProductTierType = ProductTierType.Enterprise,
StripePlanId = "2021-family-for-enterprise-annually",
UsersCanSponsor = (OrganizationUserOrganizationDetails org) =>
org.PlanType.GetProductTier() == ProductTierType.Enterprise,
}
};
[Obsolete("Use PricingClient.GetPlan to retrieve a plan.")]
public static Plan GetPlan(PlanType planType) => Plans.SingleOrDefault(p => p.Type == planType);
public static SponsoredPlan GetSponsoredPlan(PlanSponsorshipType planSponsorshipType) =>
SponsoredPlans.FirstOrDefault(p => p.PlanSponsorshipType == planSponsorshipType);
}

View File

@@ -17,7 +17,7 @@ public interface ICipherService
Task CreateAttachmentAsync(Cipher cipher, Stream stream, string fileName, string key,
long requestLength, Guid savingUserId, bool orgAdmin = false, DateTime? lastKnownRevisionDate = null);
Task CreateAttachmentShareAsync(Cipher cipher, Stream stream, string fileName, string key, long requestLength,
string attachmentId, Guid organizationShareId, DateTime? lastKnownRevisionDate = null);
string attachmentId, Guid organizationShareId);
Task DeleteAsync(CipherDetails cipherDetails, Guid deletingUserId, bool orgAdmin = false);
Task DeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false);
Task<DeleteAttachmentResponseData> DeleteAttachmentAsync(Cipher cipher, string attachmentId, Guid deletingUserId, bool orgAdmin = false);
@@ -34,7 +34,7 @@ public interface ICipherService
Task SoftDeleteManyAsync(IEnumerable<Guid> cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false);
Task RestoreAsync(CipherDetails cipherDetails, Guid restoringUserId, bool orgAdmin = false);
Task<ICollection<CipherOrganizationDetails>> RestoreManyAsync(IEnumerable<Guid> cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false);
Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId, DateTime? lastKnownRevisionDate = null);
Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId);
Task<AttachmentResponseData> GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId);
Task<bool> ValidateCipherAttachmentFile(Cipher cipher, CipherAttachment.MetaData attachmentData);
Task ValidateBulkCollectionAssignmentAsync(IEnumerable<Guid> collectionIds, IEnumerable<Guid> cipherIds, Guid userId);

View File

@@ -183,9 +183,8 @@ public class CipherService : ICipherService
}
}
public async Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachment, DateTime? lastKnownRevisionDate = null)
public async Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachment)
{
ValidateCipherLastKnownRevisionDate(cipher, lastKnownRevisionDate);
if (attachment == null)
{
throw new BadRequestException("Cipher attachment does not exist");
@@ -290,11 +289,10 @@ public class CipherService : ICipherService
}
public async Task CreateAttachmentShareAsync(Cipher cipher, Stream stream, string fileName, string key,
long requestLength, string attachmentId, Guid organizationId, DateTime? lastKnownRevisionDate = null)
long requestLength, string attachmentId, Guid organizationId)
{
try
{
ValidateCipherLastKnownRevisionDate(cipher, lastKnownRevisionDate);
if (requestLength < 1)
{
throw new BadRequestException("No data to attach.");

View File

@@ -2,6 +2,7 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.Utilities.DebuggingInstruments;
using Bit.Core.Entities;
@@ -671,7 +672,7 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
commandType: CommandType.StoredProcedure);
}
public async Task<bool> ConfirmOrganizationUserAsync(OrganizationUser organizationUser)
public async Task<bool> ConfirmOrganizationUserAsync(AcceptedOrganizationUserToConfirm organizationUserToConfirm)
{
await using var connection = new SqlConnection(_marsConnectionString);
@@ -679,10 +680,10 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
$"[{Schema}].[OrganizationUser_ConfirmById]",
new
{
organizationUser.Id,
organizationUser.UserId,
Id = organizationUserToConfirm.OrganizationUserId,
UserId = organizationUserToConfirm.UserId,
RevisionDate = DateTime.UtcNow.Date,
Key = organizationUser.Key
Key = organizationUserToConfirm.Key
});
return rowCount > 0;

View File

@@ -148,4 +148,16 @@ public class OrganizationDomainRepository : Repository<OrganizationDomain, Guid>
commandType: CommandType.StoredProcedure) > 0;
}
}
public async Task<bool> HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(string domainName, Guid? excludeOrganizationId = null)
{
await using var connection = new SqlConnection(ConnectionString);
var result = await connection.QueryFirstOrDefaultAsync<bool>(
$"[{Schema}].[OrganizationDomain_HasVerifiedDomainWithBlockPolicy]",
new { DomainName = domainName, ExcludeOrganizationId = excludeOrganizationId },
commandType: CommandType.StoredProcedure);
return result;
}
}

View File

@@ -3,6 +3,7 @@
using AutoMapper;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@@ -943,23 +944,24 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
await dbContext.SaveChangesAsync();
}
public async Task<bool> ConfirmOrganizationUserAsync(Core.Entities.OrganizationUser organizationUser)
public async Task<bool> ConfirmOrganizationUserAsync(AcceptedOrganizationUserToConfirm organizationUserToConfirm)
{
using var scope = ServiceScopeFactory.CreateScope();
await using var dbContext = GetDatabaseContext(scope);
var result = await dbContext.OrganizationUsers
.Where(ou => ou.Id == organizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted)
.Where(ou => ou.Id == organizationUserToConfirm.OrganizationUserId
&& ou.Status == OrganizationUserStatusType.Accepted)
.ExecuteUpdateAsync(x => x
.SetProperty(y => y.Status, OrganizationUserStatusType.Confirmed)
.SetProperty(y => y.Key, organizationUser.Key));
.SetProperty(y => y.Key, organizationUserToConfirm.Key));
if (result <= 0)
{
return false;
}
await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdAsync(organizationUser.Id);
await dbContext.UserBumpAccountRevisionDateByOrganizationUserIdAsync(organizationUserToConfirm.OrganizationUserId);
return true;
}

View File

@@ -217,7 +217,7 @@ public class PolicyRepository : Repository<AdminConsoleEntities.Policy, Policy,
UserId = u.Id
}).ToListAsync();
// Combine results with provder lookup
// Combine results with the provider lookup
var allResults = acceptedUsers.Concat(invitedUsers)
.Select(item => new OrganizationPolicyDetails
{

View File

@@ -177,5 +177,25 @@ public class OrganizationDomainRepository : Repository<Core.Entities.Organizatio
return Mapper.Map<List<OrganizationDomain>>(verifiedDomains);
}
public async Task<bool> HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(string domainName, Guid? excludeOrganizationId = null)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var query = from od in dbContext.OrganizationDomains
join o in dbContext.Organizations on od.OrganizationId equals o.Id
join p in dbContext.Policies on o.Id equals p.OrganizationId
where od.DomainName == domainName
&& od.VerifiedDate != null
&& o.Enabled
&& o.UsePolicies
&& o.UseOrganizationDomains
&& (!excludeOrganizationId.HasValue || o.Id != excludeOrganizationId.Value)
&& p.Type == Core.AdminConsole.Enums.PolicyType.BlockClaimedDomainAccountCreation
&& p.Enabled
select od;
return await query.AnyAsync();
}
}

View File

@@ -645,7 +645,7 @@ public static class ServiceCollectionExtensions
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
};
if (!globalSettings.UnifiedDeployment)
if (!globalSettings.LiteDeployment)
{
// Trust the X-Forwarded-Host header of the nginx docker container
try

View File

@@ -0,0 +1,34 @@
CREATE PROCEDURE [dbo].[OrganizationDomain_HasVerifiedDomainWithBlockPolicy]
@DomainName NVARCHAR(255),
@ExcludeOrganizationId UNIQUEIDENTIFIER = NULL
AS
BEGIN
SET NOCOUNT ON
-- Check if any organization has a verified domain matching the domain name
-- with the BlockClaimedDomainAccountCreation policy enabled (Type = 19)
-- If @ExcludeOrganizationId is provided, exclude that organization from the check
IF EXISTS (
SELECT 1
FROM [dbo].[OrganizationDomain] OD
INNER JOIN [dbo].[Organization] O
ON OD.OrganizationId = O.Id
INNER JOIN [dbo].[Policy] P
ON O.Id = P.OrganizationId
WHERE OD.DomainName = @DomainName
AND OD.VerifiedDate IS NOT NULL
AND O.Enabled = 1
AND O.UsePolicies = 1
AND O.UseOrganizationDomains = 1
AND (@ExcludeOrganizationId IS NULL OR O.Id != @ExcludeOrganizationId)
AND P.Type = 19 -- BlockClaimedDomainAccountCreation
AND P.Enabled = 1
)
BEGIN
SELECT CAST(1 AS BIT) AS HasBlockPolicy
END
ELSE
BEGIN
SELECT CAST(0 AS BIT) AS HasBlockPolicy
END
END

View File

@@ -60,6 +60,7 @@ CREATE TABLE [dbo].[Organization] (
[UseAdminSponsoredFamilies] BIT NOT NULL CONSTRAINT [DF_Organization_UseAdminSponsoredFamilies] DEFAULT (0),
[SyncSeats] BIT NOT NULL CONSTRAINT [DF_Organization_SyncSeats] DEFAULT (0),
[UseAutomaticUserConfirmation] BIT NOT NULL CONSTRAINT [DF_Organization_UseAutomaticUserConfirmation] DEFAULT (0),
[MaxStorageGbIncreased] SMALLINT NULL,
CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC)
);

View File

@@ -45,6 +45,7 @@
[SecurityState] VARCHAR (MAX) NULL,
[SecurityVersion] INT NULL,
[SignedPublicKey] VARCHAR (MAX) NULL,
[MaxStorageGbIncreased] SMALLINT NULL,
CONSTRAINT [PK_User] PRIMARY KEY CLUSTERED ([Id] ASC)
);

View File

@@ -24,7 +24,7 @@ SELECT
O.[UseSecretsManager],
O.[Seats],
O.[MaxCollections],
O.[MaxStorageGb],
COALESCE(O.[MaxStorageGbIncreased], O.[MaxStorageGb]) AS [MaxStorageGb],
O.[Identifier],
OU.[Key],
OU.[ResetPasswordKey],

View File

@@ -1,6 +1,66 @@
CREATE VIEW [dbo].[OrganizationView]
AS
SELECT
*
[Id],
[Identifier],
[Name],
[BusinessName],
[BusinessAddress1],
[BusinessAddress2],
[BusinessAddress3],
[BusinessCountry],
[BusinessTaxNumber],
[BillingEmail],
[Plan],
[PlanType],
[Seats],
[MaxCollections],
[UsePolicies],
[UseSso],
[UseGroups],
[UseDirectory],
[UseEvents],
[UseTotp],
[Use2fa],
[UseApi],
[UseResetPassword],
[SelfHost],
[UsersGetPremium],
[Storage],
COALESCE([MaxStorageGbIncreased], [MaxStorageGb]) AS [MaxStorageGb],
[Gateway],
[GatewayCustomerId],
[GatewaySubscriptionId],
[ReferenceData],
[Enabled],
[LicenseKey],
[PublicKey],
[PrivateKey],
[TwoFactorProviders],
[ExpirationDate],
[CreationDate],
[RevisionDate],
[OwnersNotifiedOfAutoscaling],
[MaxAutoscaleSeats],
[UseKeyConnector],
[UseScim],
[UseCustomPermissions],
[UseSecretsManager],
[Status],
[UsePasswordManager],
[SmSeats],
[SmServiceAccounts],
[MaxAutoscaleSmSeats],
[MaxAutoscaleSmServiceAccounts],
[SecretsManagerBeta],
[LimitCollectionCreation],
[LimitCollectionDeletion],
[LimitItemDeletion],
[AllowAdminAccessToAllCollectionItems],
[UseRiskInsights],
[UseOrganizationDomains],
[UseAdminSponsoredFamilies],
[SyncSeats],
[UseAutomaticUserConfirmation]
FROM
[dbo].[Organization]

View File

@@ -23,7 +23,7 @@ SELECT
O.[UseCustomPermissions],
O.[Seats],
O.[MaxCollections],
O.[MaxStorageGb],
COALESCE(O.[MaxStorageGbIncreased], O.[MaxStorageGb]) AS [MaxStorageGb],
O.[Identifier],
PO.[Key],
O.[PublicKey],

View File

@@ -1,6 +1,51 @@
CREATE VIEW [dbo].[UserView]
AS
SELECT
*
[Id],
[Name],
[Email],
[EmailVerified],
[MasterPassword],
[MasterPasswordHint],
[Culture],
[SecurityStamp],
[TwoFactorProviders],
[TwoFactorRecoveryCode],
[EquivalentDomains],
[ExcludedGlobalEquivalentDomains],
[AccountRevisionDate],
[Key],
[PublicKey],
[PrivateKey],
[Premium],
[PremiumExpirationDate],
[RenewalReminderDate],
[Storage],
COALESCE([MaxStorageGbIncreased], [MaxStorageGb]) AS [MaxStorageGb],
[Gateway],
[GatewayCustomerId],
[GatewaySubscriptionId],
[ReferenceData],
[LicenseKey],
[ApiKey],
[Kdf],
[KdfIterations],
[KdfMemory],
[KdfParallelism],
[CreationDate],
[RevisionDate],
[ForcePasswordReset],
[UsesKeyConnector],
[FailedLoginCount],
[LastFailedLoginDate],
[AvatarColor],
[LastPasswordChangeDate],
[LastKdfChangeDate],
[LastKeyRotationDate],
[LastEmailChangeDate],
[VerifyDevices],
[SecurityState],
[SecurityVersion],
[SignedPublicKey]
FROM
[dbo].[User]