mirror of
https://github.com/bitwarden/server
synced 2026-01-05 18:13:31 +00:00
Merge branch 'master' into feature/flexible-collections
This commit is contained in:
@@ -37,6 +37,7 @@ public static class FeatureFlagKeys
|
||||
public const string DisplayLowKdfIterationWarning = "display-kdf-iteration-warning";
|
||||
public const string TrustedDeviceEncryption = "trusted-device-encryption";
|
||||
public const string SecretsManagerBilling = "sm-ga-billing";
|
||||
public const string AutofillV2 = "autofill-v2";
|
||||
|
||||
public static List<string> GetAllKeys()
|
||||
{
|
||||
|
||||
@@ -26,8 +26,8 @@ public class CurrentContext : ICurrentContext
|
||||
public virtual string DeviceIdentifier { get; set; }
|
||||
public virtual DeviceType? DeviceType { get; set; }
|
||||
public virtual string IpAddress { get; set; }
|
||||
public virtual List<CurrentContentOrganization> Organizations { get; set; }
|
||||
public virtual List<CurrentContentProvider> Providers { get; set; }
|
||||
public virtual List<CurrentContextOrganization> Organizations { get; set; }
|
||||
public virtual List<CurrentContextProvider> Providers { get; set; }
|
||||
public virtual Guid? InstallationId { get; set; }
|
||||
public virtual Guid? OrganizationId { get; set; }
|
||||
public virtual bool CloudflareWorkerProxied { get; set; }
|
||||
@@ -166,17 +166,17 @@ public class CurrentContext : ICurrentContext
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
private List<CurrentContentOrganization> GetOrganizations(Dictionary<string, IEnumerable<Claim>> claimsDict, bool orgApi)
|
||||
private List<CurrentContextOrganization> GetOrganizations(Dictionary<string, IEnumerable<Claim>> claimsDict, bool orgApi)
|
||||
{
|
||||
var accessSecretsManager = claimsDict.ContainsKey(Claims.SecretsManagerAccess)
|
||||
? claimsDict[Claims.SecretsManagerAccess].ToDictionary(s => s.Value, _ => true)
|
||||
: new Dictionary<string, bool>();
|
||||
|
||||
var organizations = new List<CurrentContentOrganization>();
|
||||
var organizations = new List<CurrentContextOrganization>();
|
||||
if (claimsDict.ContainsKey(Claims.OrganizationOwner))
|
||||
{
|
||||
organizations.AddRange(claimsDict[Claims.OrganizationOwner].Select(c =>
|
||||
new CurrentContentOrganization
|
||||
new CurrentContextOrganization
|
||||
{
|
||||
Id = new Guid(c.Value),
|
||||
Type = OrganizationUserType.Owner,
|
||||
@@ -185,7 +185,7 @@ public class CurrentContext : ICurrentContext
|
||||
}
|
||||
else if (orgApi && OrganizationId.HasValue)
|
||||
{
|
||||
organizations.Add(new CurrentContentOrganization
|
||||
organizations.Add(new CurrentContextOrganization
|
||||
{
|
||||
Id = OrganizationId.Value,
|
||||
Type = OrganizationUserType.Owner,
|
||||
@@ -195,7 +195,7 @@ public class CurrentContext : ICurrentContext
|
||||
if (claimsDict.ContainsKey(Claims.OrganizationAdmin))
|
||||
{
|
||||
organizations.AddRange(claimsDict[Claims.OrganizationAdmin].Select(c =>
|
||||
new CurrentContentOrganization
|
||||
new CurrentContextOrganization
|
||||
{
|
||||
Id = new Guid(c.Value),
|
||||
Type = OrganizationUserType.Admin,
|
||||
@@ -206,7 +206,7 @@ public class CurrentContext : ICurrentContext
|
||||
if (claimsDict.ContainsKey(Claims.OrganizationUser))
|
||||
{
|
||||
organizations.AddRange(claimsDict[Claims.OrganizationUser].Select(c =>
|
||||
new CurrentContentOrganization
|
||||
new CurrentContextOrganization
|
||||
{
|
||||
Id = new Guid(c.Value),
|
||||
Type = OrganizationUserType.User,
|
||||
@@ -217,7 +217,7 @@ public class CurrentContext : ICurrentContext
|
||||
if (claimsDict.ContainsKey(Claims.OrganizationManager))
|
||||
{
|
||||
organizations.AddRange(claimsDict[Claims.OrganizationManager].Select(c =>
|
||||
new CurrentContentOrganization
|
||||
new CurrentContextOrganization
|
||||
{
|
||||
Id = new Guid(c.Value),
|
||||
Type = OrganizationUserType.Manager,
|
||||
@@ -228,7 +228,7 @@ public class CurrentContext : ICurrentContext
|
||||
if (claimsDict.ContainsKey(Claims.OrganizationCustom))
|
||||
{
|
||||
organizations.AddRange(claimsDict[Claims.OrganizationCustom].Select(c =>
|
||||
new CurrentContentOrganization
|
||||
new CurrentContextOrganization
|
||||
{
|
||||
Id = new Guid(c.Value),
|
||||
Type = OrganizationUserType.Custom,
|
||||
@@ -240,13 +240,13 @@ public class CurrentContext : ICurrentContext
|
||||
return organizations;
|
||||
}
|
||||
|
||||
private List<CurrentContentProvider> GetProviders(Dictionary<string, IEnumerable<Claim>> claimsDict)
|
||||
private List<CurrentContextProvider> GetProviders(Dictionary<string, IEnumerable<Claim>> claimsDict)
|
||||
{
|
||||
var providers = new List<CurrentContentProvider>();
|
||||
var providers = new List<CurrentContextProvider>();
|
||||
if (claimsDict.ContainsKey(Claims.ProviderAdmin))
|
||||
{
|
||||
providers.AddRange(claimsDict[Claims.ProviderAdmin].Select(c =>
|
||||
new CurrentContentProvider
|
||||
new CurrentContextProvider
|
||||
{
|
||||
Id = new Guid(c.Value),
|
||||
Type = ProviderUserType.ProviderAdmin
|
||||
@@ -256,7 +256,7 @@ public class CurrentContext : ICurrentContext
|
||||
if (claimsDict.ContainsKey(Claims.ProviderServiceUser))
|
||||
{
|
||||
providers.AddRange(claimsDict[Claims.ProviderServiceUser].Select(c =>
|
||||
new CurrentContentProvider
|
||||
new CurrentContextProvider
|
||||
{
|
||||
Id = new Guid(c.Value),
|
||||
Type = ProviderUserType.ServiceUser
|
||||
@@ -483,26 +483,26 @@ public class CurrentContext : ICurrentContext
|
||||
return Organizations?.Any(o => o.Id == orgId && o.AccessSecretsManager) ?? false;
|
||||
}
|
||||
|
||||
public async Task<ICollection<CurrentContentOrganization>> OrganizationMembershipAsync(
|
||||
public async Task<ICollection<CurrentContextOrganization>> OrganizationMembershipAsync(
|
||||
IOrganizationUserRepository organizationUserRepository, Guid userId)
|
||||
{
|
||||
if (Organizations == null)
|
||||
{
|
||||
var userOrgs = await organizationUserRepository.GetManyDetailsByUserAsync(userId);
|
||||
Organizations = userOrgs.Where(ou => ou.Status == OrganizationUserStatusType.Confirmed)
|
||||
.Select(ou => new CurrentContentOrganization(ou)).ToList();
|
||||
.Select(ou => new CurrentContextOrganization(ou)).ToList();
|
||||
}
|
||||
return Organizations;
|
||||
}
|
||||
|
||||
public async Task<ICollection<CurrentContentProvider>> ProviderMembershipAsync(
|
||||
public async Task<ICollection<CurrentContextProvider>> ProviderMembershipAsync(
|
||||
IProviderUserRepository providerUserRepository, Guid userId)
|
||||
{
|
||||
if (Providers == null)
|
||||
{
|
||||
var userProviders = await providerUserRepository.GetManyByUserAsync(userId);
|
||||
Providers = userProviders.Where(ou => ou.Status == ProviderUserStatusType.Confirmed)
|
||||
.Select(ou => new CurrentContentProvider(ou)).ToList();
|
||||
.Select(ou => new CurrentContextProvider(ou)).ToList();
|
||||
}
|
||||
return Providers;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Context;
|
||||
|
||||
public class CurrentContentOrganization
|
||||
public class CurrentContextOrganization
|
||||
{
|
||||
public CurrentContentOrganization() { }
|
||||
public CurrentContextOrganization() { }
|
||||
|
||||
public CurrentContentOrganization(OrganizationUserOrganizationDetails orgUser)
|
||||
public CurrentContextOrganization(OrganizationUserOrganizationDetails orgUser)
|
||||
{
|
||||
Id = orgUser.OrganizationId;
|
||||
Type = orgUser.Type;
|
||||
@@ -5,11 +5,11 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Context;
|
||||
|
||||
public class CurrentContentProvider
|
||||
public class CurrentContextProvider
|
||||
{
|
||||
public CurrentContentProvider() { }
|
||||
public CurrentContextProvider() { }
|
||||
|
||||
public CurrentContentProvider(ProviderUser providerUser)
|
||||
public CurrentContextProvider(ProviderUser providerUser)
|
||||
{
|
||||
Id = providerUser.ProviderId;
|
||||
Type = providerUser.Type;
|
||||
@@ -16,7 +16,7 @@ public interface ICurrentContext
|
||||
string DeviceIdentifier { get; set; }
|
||||
DeviceType? DeviceType { get; set; }
|
||||
string IpAddress { get; set; }
|
||||
List<CurrentContentOrganization> Organizations { get; set; }
|
||||
List<CurrentContextOrganization> Organizations { get; set; }
|
||||
Guid? InstallationId { get; set; }
|
||||
Guid? OrganizationId { get; set; }
|
||||
ClientType ClientType { get; set; }
|
||||
@@ -64,10 +64,10 @@ public interface ICurrentContext
|
||||
bool AccessProviderOrganizations(Guid providerId);
|
||||
bool ManageProviderOrganizations(Guid providerId);
|
||||
|
||||
Task<ICollection<CurrentContentOrganization>> OrganizationMembershipAsync(
|
||||
Task<ICollection<CurrentContextOrganization>> OrganizationMembershipAsync(
|
||||
IOrganizationUserRepository organizationUserRepository, Guid userId);
|
||||
|
||||
Task<ICollection<CurrentContentProvider>> ProviderMembershipAsync(
|
||||
Task<ICollection<CurrentContextProvider>> ProviderMembershipAsync(
|
||||
IProviderUserRepository providerUserRepository, Guid userId);
|
||||
|
||||
Task<Guid?> ProviderIdForOrg(Guid orgId);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Bit.Core.Models.Business;
|
||||
|
||||
public class SecretsManagerSubscriptionUpdate
|
||||
{
|
||||
public Organization Organization { get; set; }
|
||||
public Organization Organization { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The total seats the organization will have after the update, including any base seats included in the plan
|
||||
@@ -14,8 +14,7 @@ public class SecretsManagerSubscriptionUpdate
|
||||
public int? SmSeats { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The new autoscale limit for seats, expressed as a total (not an adjustment).
|
||||
/// This may or may not be the same as the current autoscale limit.
|
||||
/// The new autoscale limit for seats after the update
|
||||
/// </summary>
|
||||
public int? MaxAutoscaleSmSeats { get; set; }
|
||||
|
||||
@@ -26,8 +25,7 @@ public class SecretsManagerSubscriptionUpdate
|
||||
public int? SmServiceAccounts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The new autoscale limit for service accounts, expressed as a total (not an adjustment).
|
||||
/// This may or may not be the same as the current autoscale limit.
|
||||
/// The new autoscale limit for service accounts after the update
|
||||
/// </summary>
|
||||
public int? MaxAutoscaleSmServiceAccounts { get; set; }
|
||||
|
||||
@@ -39,7 +37,7 @@ public class SecretsManagerSubscriptionUpdate
|
||||
/// <summary>
|
||||
/// Whether the subscription update is a result of autoscaling
|
||||
/// </summary>
|
||||
public bool Autoscaling { get; init; }
|
||||
public bool Autoscaling { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The seats the organization will have after the update, excluding the base seats included in the plan
|
||||
@@ -57,18 +55,11 @@ public class SecretsManagerSubscriptionUpdate
|
||||
public bool MaxAutoscaleSmServiceAccountsChanged =>
|
||||
MaxAutoscaleSmServiceAccounts != Organization.MaxAutoscaleSmServiceAccounts;
|
||||
public Plan Plan => Utilities.StaticStore.GetSecretsManagerPlan(Organization.PlanType);
|
||||
public bool SmSeatAutoscaleLimitReached => SmSeats.HasValue && MaxAutoscaleSmSeats.HasValue && SmSeats == MaxAutoscaleSmSeats;
|
||||
|
||||
public SecretsManagerSubscriptionUpdate(
|
||||
Organization organization,
|
||||
int seatAdjustment, int? maxAutoscaleSeats,
|
||||
int serviceAccountAdjustment, int? maxAutoscaleServiceAccounts) : this(organization, false)
|
||||
{
|
||||
AdjustSeats(seatAdjustment);
|
||||
AdjustServiceAccounts(serviceAccountAdjustment);
|
||||
|
||||
MaxAutoscaleSmSeats = maxAutoscaleSeats;
|
||||
MaxAutoscaleSmServiceAccounts = maxAutoscaleServiceAccounts;
|
||||
}
|
||||
public bool SmServiceAccountAutoscaleLimitReached => SmServiceAccounts.HasValue &&
|
||||
MaxAutoscaleSmServiceAccounts.HasValue &&
|
||||
SmServiceAccounts == MaxAutoscaleSmServiceAccounts;
|
||||
|
||||
public SecretsManagerSubscriptionUpdate(Organization organization, bool autoscaling)
|
||||
{
|
||||
@@ -91,13 +82,15 @@ public class SecretsManagerSubscriptionUpdate
|
||||
Autoscaling = autoscaling;
|
||||
}
|
||||
|
||||
public void AdjustSeats(int adjustment)
|
||||
public SecretsManagerSubscriptionUpdate AdjustSeats(int adjustment)
|
||||
{
|
||||
SmSeats = SmSeats.GetValueOrDefault() + adjustment;
|
||||
return this;
|
||||
}
|
||||
|
||||
public void AdjustServiceAccounts(int adjustment)
|
||||
public SecretsManagerSubscriptionUpdate AdjustServiceAccounts(int adjustment)
|
||||
{
|
||||
SmServiceAccounts = SmServiceAccounts.GetValueOrDefault() + adjustment;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public class OrganizationAbility
|
||||
UseScim = organization.UseScim;
|
||||
UseResetPassword = organization.UseResetPassword;
|
||||
UseCustomPermissions = organization.UseCustomPermissions;
|
||||
UsePolicies = organization.UsePolicies;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
@@ -33,4 +34,5 @@ public class OrganizationAbility
|
||||
public bool UseScim { get; set; }
|
||||
public bool UseResetPassword { get; set; }
|
||||
public bool UseCustomPermissions { get; set; }
|
||||
public bool UsePolicies { get; set; }
|
||||
}
|
||||
|
||||
@@ -62,6 +62,17 @@ public class AddSecretsManagerSubscriptionCommand : IAddSecretsManagerSubscripti
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (organization.SecretsManagerBeta)
|
||||
{
|
||||
throw new BadRequestException("Organization is enrolled in Secrets Manager Beta. " +
|
||||
"Please contact Customer Success to add Secrets Manager to your subscription.");
|
||||
}
|
||||
|
||||
if (organization.UseSecretsManager)
|
||||
{
|
||||
throw new BadRequestException("Organization already uses Secrets Manager.");
|
||||
}
|
||||
|
||||
var plan = StaticStore.GetSecretsManagerPlan(organization.PlanType);
|
||||
if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId) && plan.Product != ProductType.Free)
|
||||
{
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.Business;
|
||||
|
||||
namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||
|
||||
public interface IUpdateSecretsManagerSubscriptionCommand
|
||||
{
|
||||
Task UpdateSubscriptionAsync(SecretsManagerSubscriptionUpdate update);
|
||||
Task AdjustServiceAccountsAsync(Organization organization, int smServiceAccountsAdjustment);
|
||||
Task ValidateUpdate(SecretsManagerSubscriptionUpdate update);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.StaticStore;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
|
||||
@@ -51,82 +49,46 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs
|
||||
{
|
||||
await ValidateUpdate(update);
|
||||
|
||||
await FinalizeSubscriptionAdjustmentAsync(update.Organization, update.Plan, update);
|
||||
await FinalizeSubscriptionAdjustmentAsync(update);
|
||||
|
||||
await SendEmailIfAutoscaleLimitReached(update.Organization);
|
||||
}
|
||||
|
||||
public async Task AdjustServiceAccountsAsync(Organization organization, int smServiceAccountsAdjustment)
|
||||
{
|
||||
var update = new SecretsManagerSubscriptionUpdate(
|
||||
organization, seatAdjustment: 0, maxAutoscaleSeats: organization?.MaxAutoscaleSmSeats,
|
||||
serviceAccountAdjustment: smServiceAccountsAdjustment, maxAutoscaleServiceAccounts: organization?.MaxAutoscaleSmServiceAccounts)
|
||||
if (update.SmSeatAutoscaleLimitReached)
|
||||
{
|
||||
Autoscaling = true
|
||||
};
|
||||
await SendSeatLimitEmailAsync(update.Organization);
|
||||
}
|
||||
|
||||
await UpdateSubscriptionAsync(update);
|
||||
if (update.SmServiceAccountAutoscaleLimitReached)
|
||||
{
|
||||
await SendServiceAccountLimitEmailAsync(update.Organization);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FinalizeSubscriptionAdjustmentAsync(Organization organization,
|
||||
Plan plan, SecretsManagerSubscriptionUpdate update)
|
||||
private async Task FinalizeSubscriptionAdjustmentAsync(SecretsManagerSubscriptionUpdate update)
|
||||
{
|
||||
if (update.SmSeatsChanged)
|
||||
{
|
||||
await ProcessChargesAndRaiseEventsForAdjustSeatsAsync(organization, plan, update);
|
||||
organization.SmSeats = update.SmSeats;
|
||||
await _paymentService.AdjustSeatsAsync(update.Organization, update.Plan, update.SmSeatsExcludingBase, update.ProrationDate);
|
||||
|
||||
// TODO: call ReferenceEventService - see AC-1481
|
||||
}
|
||||
|
||||
if (update.SmServiceAccountsChanged)
|
||||
{
|
||||
await ProcessChargesAndRaiseEventsForAdjustServiceAccountsAsync(organization, plan, update);
|
||||
organization.SmServiceAccounts = update.SmServiceAccounts;
|
||||
await _paymentService.AdjustServiceAccountsAsync(update.Organization, update.Plan,
|
||||
update.SmServiceAccountsExcludingBase, update.ProrationDate);
|
||||
|
||||
// TODO: call ReferenceEventService - see AC-1481
|
||||
}
|
||||
|
||||
if (update.MaxAutoscaleSmSeatsChanged)
|
||||
{
|
||||
organization.MaxAutoscaleSmSeats = update.MaxAutoscaleSmSeats;
|
||||
}
|
||||
|
||||
if (update.MaxAutoscaleSmServiceAccountsChanged)
|
||||
{
|
||||
organization.MaxAutoscaleSmServiceAccounts = update.MaxAutoscaleSmServiceAccounts;
|
||||
}
|
||||
var organization = update.Organization;
|
||||
organization.SmSeats = update.SmSeats;
|
||||
organization.SmServiceAccounts = update.SmServiceAccounts;
|
||||
organization.MaxAutoscaleSmSeats = update.MaxAutoscaleSmSeats;
|
||||
organization.MaxAutoscaleSmServiceAccounts = update.MaxAutoscaleSmServiceAccounts;
|
||||
|
||||
await ReplaceAndUpdateCacheAsync(organization);
|
||||
}
|
||||
|
||||
private async Task ProcessChargesAndRaiseEventsForAdjustSeatsAsync(Organization organization, Plan plan,
|
||||
SecretsManagerSubscriptionUpdate update)
|
||||
{
|
||||
await _paymentService.AdjustSeatsAsync(organization, plan, update.SmSeatsExcludingBase, update.ProrationDate);
|
||||
|
||||
// TODO: call ReferenceEventService - see AC-1481
|
||||
}
|
||||
|
||||
private async Task ProcessChargesAndRaiseEventsForAdjustServiceAccountsAsync(Organization organization, Plan plan,
|
||||
SecretsManagerSubscriptionUpdate update)
|
||||
{
|
||||
await _paymentService.AdjustServiceAccountsAsync(organization, plan,
|
||||
update.SmServiceAccountsExcludingBase, update.ProrationDate);
|
||||
|
||||
// TODO: call ReferenceEventService - see AC-1481
|
||||
}
|
||||
|
||||
private async Task SendEmailIfAutoscaleLimitReached(Organization organization)
|
||||
{
|
||||
if (organization.SmSeats.HasValue && organization.MaxAutoscaleSmSeats.HasValue && organization.SmSeats == organization.MaxAutoscaleSmSeats)
|
||||
{
|
||||
await SendSeatLimitEmailAsync(organization, organization.MaxAutoscaleSmSeats.Value);
|
||||
}
|
||||
|
||||
if (organization.SmServiceAccounts.HasValue && organization.MaxAutoscaleSmServiceAccounts.HasValue && organization.SmServiceAccounts == organization.MaxAutoscaleSmServiceAccounts)
|
||||
{
|
||||
await SendServiceAccountLimitEmailAsync(organization, organization.MaxAutoscaleSmServiceAccounts.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendSeatLimitEmailAsync(Organization organization, int MaxAutoscaleValue)
|
||||
private async Task SendSeatLimitEmailAsync(Organization organization)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -134,16 +96,16 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs
|
||||
OrganizationUserType.Owner))
|
||||
.Select(u => u.Email).Distinct();
|
||||
|
||||
await _mailService.SendSecretsManagerMaxSeatLimitReachedEmailAsync(organization, MaxAutoscaleValue, ownerEmails);
|
||||
await _mailService.SendSecretsManagerMaxSeatLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmSeats.Value, ownerEmails);
|
||||
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, $"Error encountered notifying organization owners of Seats limit reached.");
|
||||
_logger.LogError(e, $"Error encountered notifying organization owners of seats limit reached.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendServiceAccountLimitEmailAsync(Organization organization, int MaxAutoscaleValue)
|
||||
private async Task SendServiceAccountLimitEmailAsync(Organization organization)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -151,12 +113,12 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs
|
||||
OrganizationUserType.Owner))
|
||||
.Select(u => u.Email).Distinct();
|
||||
|
||||
await _mailService.SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(organization, MaxAutoscaleValue, ownerEmails);
|
||||
await _mailService.SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmServiceAccounts.Value, ownerEmails);
|
||||
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, $"Error encountered notifying organization owners of Service Accounts limit reached.");
|
||||
_logger.LogError(e, $"Error encountered notifying organization owners of service accounts limit reached.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -171,46 +133,45 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
|
||||
var organization = update.Organization;
|
||||
ValidateOrganization(organization);
|
||||
|
||||
var plan = GetPlanForOrganization(organization);
|
||||
ValidateOrganization(update);
|
||||
|
||||
if (update.SmSeatsChanged)
|
||||
{
|
||||
await ValidateSmSeatsUpdateAsync(organization, update, plan);
|
||||
await ValidateSmSeatsUpdateAsync(update);
|
||||
}
|
||||
|
||||
if (update.SmServiceAccountsChanged)
|
||||
{
|
||||
await ValidateSmServiceAccountsUpdateAsync(organization, update, plan);
|
||||
await ValidateSmServiceAccountsUpdateAsync(update);
|
||||
}
|
||||
|
||||
if (update.MaxAutoscaleSmSeatsChanged)
|
||||
{
|
||||
ValidateMaxAutoscaleSmSeatsUpdateAsync(organization, update.MaxAutoscaleSmSeats, plan);
|
||||
ValidateMaxAutoscaleSmSeatsUpdateAsync(update);
|
||||
}
|
||||
|
||||
if (update.MaxAutoscaleSmServiceAccountsChanged)
|
||||
{
|
||||
ValidateMaxAutoscaleSmServiceAccountUpdate(organization, update.MaxAutoscaleSmServiceAccounts, plan);
|
||||
ValidateMaxAutoscaleSmServiceAccountUpdate(update);
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateOrganization(Organization organization)
|
||||
private void ValidateOrganization(SecretsManagerSubscriptionUpdate update)
|
||||
{
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException("Organization is not found.");
|
||||
}
|
||||
var organization = update.Organization;
|
||||
|
||||
if (!organization.UseSecretsManager)
|
||||
{
|
||||
throw new BadRequestException("Organization has no access to Secrets Manager.");
|
||||
}
|
||||
|
||||
var plan = GetPlanForOrganization(organization);
|
||||
if (plan.Product == ProductType.Free)
|
||||
if (organization.SecretsManagerBeta)
|
||||
{
|
||||
throw new BadRequestException("Organization is enrolled in Secrets Manager Beta. " +
|
||||
"Please contact Customer Success to add Secrets Manager to your subscription.");
|
||||
}
|
||||
|
||||
if (update.Plan.Product == ProductType.Free)
|
||||
{
|
||||
// No need to check the organization is set up with Stripe
|
||||
return;
|
||||
@@ -227,18 +188,11 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs
|
||||
}
|
||||
}
|
||||
|
||||
private Plan GetPlanForOrganization(Organization organization)
|
||||
private async Task ValidateSmSeatsUpdateAsync(SecretsManagerSubscriptionUpdate update)
|
||||
{
|
||||
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType);
|
||||
if (plan == null)
|
||||
{
|
||||
throw new BadRequestException("Existing plan not found.");
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
var organization = update.Organization;
|
||||
var plan = update.Plan;
|
||||
|
||||
private async Task ValidateSmSeatsUpdateAsync(Organization organization, SecretsManagerSubscriptionUpdate update, Plan plan)
|
||||
{
|
||||
// Check if the organization has unlimited seats
|
||||
if (organization.SmSeats == null)
|
||||
{
|
||||
@@ -282,21 +236,24 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs
|
||||
// Check minimum seats currently in use by the organization
|
||||
if (organization.SmSeats.Value > update.SmSeats.Value)
|
||||
{
|
||||
var currentSeats = await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id);
|
||||
if (currentSeats > update.SmSeats.Value)
|
||||
var occupiedSeats = await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id);
|
||||
if (occupiedSeats > update.SmSeats.Value)
|
||||
{
|
||||
throw new BadRequestException($"Your organization currently has {currentSeats} Secrets Manager seats. " +
|
||||
$"Your plan only allows {update.SmSeats} Secrets Manager seats. Remove some Secrets Manager users.");
|
||||
throw new BadRequestException($"{occupiedSeats} users are currently occupying Secrets Manager seats. " +
|
||||
"You cannot decrease your subscription below your current occupied seat count.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ValidateSmServiceAccountsUpdateAsync(Organization organization, SecretsManagerSubscriptionUpdate update, Plan plan)
|
||||
private async Task ValidateSmServiceAccountsUpdateAsync(SecretsManagerSubscriptionUpdate update)
|
||||
{
|
||||
var organization = update.Organization;
|
||||
var plan = update.Plan;
|
||||
|
||||
// Check if the organization has unlimited service accounts
|
||||
if (organization.SmServiceAccounts == null)
|
||||
{
|
||||
throw new BadRequestException("Organization has no Service Accounts limit, no need to adjust Service Accounts");
|
||||
throw new BadRequestException("Organization has no service accounts limit, no need to adjust service accounts");
|
||||
}
|
||||
|
||||
if (update.Autoscaling && update.SmServiceAccounts.Value < organization.SmServiceAccounts.Value)
|
||||
@@ -326,13 +283,13 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs
|
||||
// Check minimum service accounts included with plan
|
||||
if (plan.BaseServiceAccount.HasValue && plan.BaseServiceAccount.Value > update.SmServiceAccounts.Value)
|
||||
{
|
||||
throw new BadRequestException($"Plan has a minimum of {plan.BaseServiceAccount} Service Accounts.");
|
||||
throw new BadRequestException($"Plan has a minimum of {plan.BaseServiceAccount} service accounts.");
|
||||
}
|
||||
|
||||
// Check minimum service accounts required by business logic
|
||||
if (update.SmServiceAccounts.Value <= 0)
|
||||
{
|
||||
throw new BadRequestException("You must have at least 1 Service Account.");
|
||||
throw new BadRequestException("You must have at least 1 service account.");
|
||||
}
|
||||
|
||||
// Check minimum service accounts currently in use by the organization
|
||||
@@ -341,30 +298,32 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs
|
||||
var currentServiceAccounts = await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(organization.Id);
|
||||
if (currentServiceAccounts > update.SmServiceAccounts)
|
||||
{
|
||||
throw new BadRequestException($"Your organization currently has {currentServiceAccounts} Service Accounts. " +
|
||||
$"Your plan only allows {update.SmServiceAccounts} Service Accounts. Remove some Service Accounts.");
|
||||
throw new BadRequestException($"Your organization currently has {currentServiceAccounts} service accounts. " +
|
||||
$"You cannot decrease your subscription below your current service account usage.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateMaxAutoscaleSmSeatsUpdateAsync(Organization organization, int? maxAutoscaleSeats, Plan plan)
|
||||
private void ValidateMaxAutoscaleSmSeatsUpdateAsync(SecretsManagerSubscriptionUpdate update)
|
||||
{
|
||||
if (!maxAutoscaleSeats.HasValue)
|
||||
var plan = update.Plan;
|
||||
|
||||
if (!update.MaxAutoscaleSmSeats.HasValue)
|
||||
{
|
||||
// autoscale limit has been turned off, no validation required
|
||||
return;
|
||||
}
|
||||
|
||||
if (organization.SmSeats.HasValue && maxAutoscaleSeats.Value < organization.SmSeats.Value)
|
||||
if (update.SmSeats.HasValue && update.MaxAutoscaleSmSeats.Value < update.SmSeats.Value)
|
||||
{
|
||||
throw new BadRequestException($"Cannot set max Secrets Manager seat autoscaling below current Secrets Manager seat count.");
|
||||
}
|
||||
|
||||
if (plan.MaxUsers.HasValue && maxAutoscaleSeats.Value > plan.MaxUsers)
|
||||
if (plan.MaxUsers.HasValue && update.MaxAutoscaleSmSeats.Value > plan.MaxUsers)
|
||||
{
|
||||
throw new BadRequestException(string.Concat(
|
||||
$"Your plan has a Secrets Manager seat limit of {plan.MaxUsers}, ",
|
||||
$"but you have specified a max autoscale count of {maxAutoscaleSeats}.",
|
||||
$"but you have specified a max autoscale count of {update.MaxAutoscaleSmSeats}.",
|
||||
"Reduce your max autoscale count."));
|
||||
}
|
||||
|
||||
@@ -374,30 +333,32 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateMaxAutoscaleSmServiceAccountUpdate(Organization organization, int? maxAutoscaleServiceAccounts, Plan plan)
|
||||
private void ValidateMaxAutoscaleSmServiceAccountUpdate(SecretsManagerSubscriptionUpdate update)
|
||||
{
|
||||
if (!maxAutoscaleServiceAccounts.HasValue)
|
||||
var plan = update.Plan;
|
||||
|
||||
if (!update.MaxAutoscaleSmServiceAccounts.HasValue)
|
||||
{
|
||||
// autoscale limit has been turned off, no validation required
|
||||
return;
|
||||
}
|
||||
|
||||
if (organization.SmServiceAccounts.HasValue && maxAutoscaleServiceAccounts.Value < organization.SmServiceAccounts.Value)
|
||||
if (update.SmServiceAccounts.HasValue && update.MaxAutoscaleSmServiceAccounts.Value < update.SmServiceAccounts.Value)
|
||||
{
|
||||
throw new BadRequestException(
|
||||
$"Cannot set max Service Accounts autoscaling below current Service Accounts count.");
|
||||
$"Cannot set max service accounts autoscaling below current service accounts count.");
|
||||
}
|
||||
|
||||
if (!plan.AllowServiceAccountsAutoscale)
|
||||
{
|
||||
throw new BadRequestException("Your plan does not allow Service Accounts autoscaling.");
|
||||
throw new BadRequestException("Your plan does not allow service accounts autoscaling.");
|
||||
}
|
||||
|
||||
if (plan.MaxServiceAccounts.HasValue && maxAutoscaleServiceAccounts.Value > plan.MaxServiceAccounts)
|
||||
if (plan.MaxServiceAccounts.HasValue && update.MaxAutoscaleSmServiceAccounts.Value > plan.MaxServiceAccounts)
|
||||
{
|
||||
throw new BadRequestException(string.Concat(
|
||||
$"Your plan has a Service Accounts limit of {plan.MaxServiceAccounts}, ",
|
||||
$"but you have specified a max autoscale count of {maxAutoscaleServiceAccounts}.",
|
||||
$"Your plan has a service account limit of {plan.MaxServiceAccounts}, ",
|
||||
$"but you have specified a max autoscale count of {update.MaxAutoscaleSmServiceAccounts}.",
|
||||
"Reduce your max autoscale count."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ public class SecretOperationRequirement : OperationAuthorizationRequirement
|
||||
public static class SecretOperations
|
||||
{
|
||||
public static readonly SecretOperationRequirement Create = new() { Name = nameof(Create) };
|
||||
public static readonly SecretOperationRequirement Read = new() { Name = nameof(Read) };
|
||||
public static readonly SecretOperationRequirement Update = new() { Name = nameof(Update) };
|
||||
public static readonly SecretOperationRequirement Delete = new() { Name = nameof(Delete) };
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ namespace Bit.Core.SecretsManager.Models.Data;
|
||||
|
||||
public class ApiKeyDetails : ApiKey
|
||||
{
|
||||
public string ClientSecret { get; set; } // Deprecated as of 2023-05-17
|
||||
|
||||
protected ApiKeyDetails() { }
|
||||
|
||||
protected ApiKeyDetails(ApiKey apiKey)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.SecretsManager.Queries.Projects.Interfaces;
|
||||
|
||||
public interface IMaxProjectsQuery
|
||||
{
|
||||
Task<(short? max, bool? atMax)> GetByOrgIdAsync(Guid organizationId);
|
||||
}
|
||||
@@ -29,4 +29,5 @@ public interface IEventService
|
||||
Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type, DateTime? date = null);
|
||||
Task LogOrganizationDomainEventAsync(OrganizationDomain organizationDomain, EventType type, EventSystemUser systemUser, DateTime? date = null);
|
||||
Task LogServiceAccountSecretEventAsync(Guid serviceAccountId, Secret secret, EventType type, DateTime? date = null);
|
||||
Task LogServiceAccountSecretsEventAsync(Guid serviceAccountId, IEnumerable<Secret> secrets, EventType type, DateTime? date = null);
|
||||
}
|
||||
|
||||
@@ -406,22 +406,34 @@ public class EventService : IEventService
|
||||
}
|
||||
|
||||
public async Task LogServiceAccountSecretEventAsync(Guid serviceAccountId, Secret secret, EventType type, DateTime? date = null)
|
||||
{
|
||||
await LogServiceAccountSecretsEventAsync(serviceAccountId, new[] { secret }, type, date);
|
||||
}
|
||||
|
||||
public async Task LogServiceAccountSecretsEventAsync(Guid serviceAccountId, IEnumerable<Secret> secrets, EventType type, DateTime? date = null)
|
||||
{
|
||||
var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||
if (!CanUseEvents(orgAbilities, secret.OrganizationId))
|
||||
var eventMessages = new List<IEvent>();
|
||||
|
||||
foreach (var secret in secrets)
|
||||
{
|
||||
return;
|
||||
if (!CanUseEvents(orgAbilities, secret.OrganizationId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var e = new EventMessage(_currentContext)
|
||||
{
|
||||
OrganizationId = secret.OrganizationId,
|
||||
Type = type,
|
||||
SecretId = secret.Id,
|
||||
ServiceAccountId = serviceAccountId,
|
||||
Date = date.GetValueOrDefault(DateTime.UtcNow)
|
||||
};
|
||||
eventMessages.Add(e);
|
||||
}
|
||||
|
||||
var e = new EventMessage(_currentContext)
|
||||
{
|
||||
OrganizationId = secret.OrganizationId,
|
||||
Type = type,
|
||||
SecretId = secret.Id,
|
||||
ServiceAccountId = serviceAccountId,
|
||||
Date = date.GetValueOrDefault(DateTime.UtcNow)
|
||||
};
|
||||
await _eventWriteService.CreateAsync(e);
|
||||
await _eventWriteService.CreateManyAsync(eventMessages);
|
||||
}
|
||||
|
||||
private async Task<Guid?> GetProviderIdAsync(Guid? orgId)
|
||||
|
||||
@@ -861,8 +861,8 @@ public class OrganizationService : IOrganizationService
|
||||
var additionalSmSeatsRequired = await _countNewSmSeatsRequiredQuery.CountNewSmSeatsRequiredAsync(organization.Id, inviteWithSmAccessCount);
|
||||
if (additionalSmSeatsRequired > 0)
|
||||
{
|
||||
smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, true);
|
||||
smSubscriptionUpdate.AdjustSeats(additionalSmSeatsRequired);
|
||||
smSubscriptionUpdate = new SecretsManagerSubscriptionUpdate(organization, true)
|
||||
.AdjustSeats(additionalSmSeatsRequired);
|
||||
await _updateSecretsManagerSubscriptionCommand.ValidateUpdate(smSubscriptionUpdate);
|
||||
}
|
||||
|
||||
@@ -1418,8 +1418,8 @@ public class OrganizationService : IOrganizationService
|
||||
if (additionalSmSeatsRequired > 0)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(user.OrganizationId);
|
||||
var update = new SecretsManagerSubscriptionUpdate(organization, true);
|
||||
update.AdjustSeats(additionalSmSeatsRequired);
|
||||
var update = new SecretsManagerSubscriptionUpdate(organization, true)
|
||||
.AdjustSeats(additionalSmSeatsRequired);
|
||||
await _updateSecretsManagerSubscriptionCommand.UpdateSubscriptionAsync(update);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace Bit.Core.Services;
|
||||
|
||||
public class PolicyService : IPolicyService
|
||||
{
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
@@ -21,6 +22,7 @@ public class PolicyService : IPolicyService
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public PolicyService(
|
||||
IApplicationCacheService applicationCacheService,
|
||||
IEventService eventService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@@ -29,6 +31,7 @@ public class PolicyService : IPolicyService
|
||||
IMailService mailService,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_eventService = eventService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@@ -199,7 +202,9 @@ public class PolicyService : IPolicyService
|
||||
{
|
||||
var organizationUserPolicyDetails = await _organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(userId, policyType);
|
||||
var excludedUserTypes = GetUserTypesExcludedFromPolicy(policyType);
|
||||
var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||
return organizationUserPolicyDetails.Where(o =>
|
||||
(!orgAbilities.ContainsKey(o.OrganizationId) || orgAbilities[o.OrganizationId].UsePolicies) &&
|
||||
o.PolicyEnabled &&
|
||||
!excludedUserTypes.Contains(o.OrganizationUserType) &&
|
||||
o.OrganizationUserStatus >= minStatus &&
|
||||
|
||||
@@ -119,4 +119,10 @@ public class NoopEventService : IEventService
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public Task LogServiceAccountSecretsEventAsync(Guid serviceAccountId, IEnumerable<Secret> secrets, EventType type,
|
||||
DateTime? date = null)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public static class CoreHelpers
|
||||
private static readonly DateTime _epoc = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime _max = new DateTime(9999, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly Random _random = new Random();
|
||||
private static readonly string CloudFlareConnectingIp = "CF-Connecting-IP";
|
||||
private static readonly string RealConnectingIp = "X-Connecting-IP";
|
||||
|
||||
/// <summary>
|
||||
/// Generate sequential Guid for Sql Server.
|
||||
@@ -50,20 +50,20 @@ public static class CoreHelpers
|
||||
{
|
||||
var guidArray = startingGuid.ToByteArray();
|
||||
|
||||
// Get the days and milliseconds which will be used to build the byte string
|
||||
// Get the days and milliseconds which will be used to build the byte string
|
||||
var days = new TimeSpan(time.Ticks - _baseDateTicks);
|
||||
var msecs = time.TimeOfDay;
|
||||
|
||||
// Convert to a byte array
|
||||
// Note that SQL Server is accurate to 1/300th of a millisecond so we divide by 3.333333
|
||||
// Convert to a byte array
|
||||
// Note that SQL Server is accurate to 1/300th of a millisecond so we divide by 3.333333
|
||||
var daysArray = BitConverter.GetBytes(days.Days);
|
||||
var msecsArray = BitConverter.GetBytes((long)(msecs.TotalMilliseconds / 3.333333));
|
||||
|
||||
// Reverse the bytes to match SQL Servers ordering
|
||||
// Reverse the bytes to match SQL Servers ordering
|
||||
Array.Reverse(daysArray);
|
||||
Array.Reverse(msecsArray);
|
||||
|
||||
// Copy the bytes into the guid
|
||||
// Copy the bytes into the guid
|
||||
Array.Copy(daysArray, daysArray.Length - 2, guidArray, guidArray.Length - 6, 2);
|
||||
Array.Copy(msecsArray, msecsArray.Length - 4, guidArray, guidArray.Length - 4, 4);
|
||||
|
||||
@@ -557,9 +557,9 @@ public static class CoreHelpers
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!globalSettings.SelfHosted && httpContext.Request.Headers.ContainsKey(CloudFlareConnectingIp))
|
||||
if (!globalSettings.SelfHosted && httpContext.Request.Headers.ContainsKey(RealConnectingIp))
|
||||
{
|
||||
return httpContext.Request.Headers[CloudFlareConnectingIp].ToString();
|
||||
return httpContext.Request.Headers[RealConnectingIp].ToString();
|
||||
}
|
||||
|
||||
return httpContext.Connection?.RemoteIpAddress?.ToString();
|
||||
@@ -624,8 +624,8 @@ public static class CoreHelpers
|
||||
return configDict;
|
||||
}
|
||||
|
||||
public static List<KeyValuePair<string, string>> BuildIdentityClaims(User user, ICollection<CurrentContentOrganization> orgs,
|
||||
ICollection<CurrentContentProvider> providers, bool isPremium)
|
||||
public static List<KeyValuePair<string, string>> BuildIdentityClaims(User user, ICollection<CurrentContextOrganization> orgs,
|
||||
ICollection<CurrentContextProvider> providers, bool isPremium)
|
||||
{
|
||||
var claims = new List<KeyValuePair<string, string>>()
|
||||
{
|
||||
@@ -817,4 +817,19 @@ public static class CoreHelpers
|
||||
.ToString();
|
||||
|
||||
}
|
||||
|
||||
public static string GetEmailDomain(string email)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
var emailParts = email.Split('@', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (emailParts.Length == 2)
|
||||
{
|
||||
return emailParts[1].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user