1
0
mirror of https://github.com/bitwarden/server synced 2025-12-27 13:43:18 +00:00

Merge branch 'main' into arch/seeder-api

This commit is contained in:
Oscar Hinton
2025-11-27 15:30:19 +01:00
committed by GitHub
391 changed files with 33562 additions and 2905 deletions

View File

@@ -16,19 +16,8 @@ public class Program
o.Limits.MaxRequestLineSize = 20_000;
});
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
{
return false;
}
return e.Level >= globalSettings.MinLogLevel.AdminSettings.Default;
}));
})
.AddSerilogFileLogging()
.Build()
.Run();
}

View File

@@ -132,11 +132,8 @@ public class Startup
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings)
{
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

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

@@ -1,16 +1,13 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
[Authorize("Application")]
public class OrganizationIntegrationConfigurationController(

View File

@@ -1,18 +1,13 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
#nullable enable
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations")]
[Authorize("Application")]
public class OrganizationIntegrationController(

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

@@ -12,7 +12,6 @@ using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
@@ -70,6 +69,7 @@ public class OrganizationsController : Controller
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient;
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
private readonly IOrganizationUpdateCommand _organizationUpdateCommand;
public OrganizationsController(
IOrganizationRepository organizationRepository,
@@ -94,7 +94,8 @@ public class OrganizationsController : Controller
IOrganizationDeleteCommand organizationDeleteCommand,
IPolicyRequirementQuery policyRequirementQuery,
IPricingClient pricingClient,
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand)
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand,
IOrganizationUpdateCommand organizationUpdateCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -119,6 +120,7 @@ public class OrganizationsController : Controller
_policyRequirementQuery = policyRequirementQuery;
_pricingClient = pricingClient;
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
_organizationUpdateCommand = organizationUpdateCommand;
}
[HttpGet("{id}")]
@@ -224,36 +226,31 @@ public class OrganizationsController : Controller
return new OrganizationResponseModel(result.Organization, plan);
}
[HttpPut("{id}")]
public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model)
[HttpPut("{organizationId:guid}")]
public async Task<IResult> Put(Guid organizationId, [FromBody] OrganizationUpdateRequestModel model)
{
var orgIdGuid = new Guid(id);
// If billing email is being changed, require subscription editing permissions.
// Otherwise, organization owner permissions are sufficient.
var requiresBillingPermission = model.BillingEmail is not null;
var authorized = requiresBillingPermission
? await _currentContext.EditSubscription(organizationId)
: await _currentContext.OrganizationOwner(organizationId);
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
if (organization == null)
if (!authorized)
{
throw new NotFoundException();
return TypedResults.Unauthorized();
}
var updateBilling = ShouldUpdateBilling(model, organization);
var commandRequest = model.ToCommandRequest(organizationId);
var updatedOrganization = await _organizationUpdateCommand.UpdateAsync(commandRequest);
var hasRequiredPermissions = updateBilling
? await _currentContext.EditSubscription(orgIdGuid)
: await _currentContext.OrganizationOwner(orgIdGuid);
if (!hasRequiredPermissions)
{
throw new NotFoundException();
}
await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling);
var plan = await _pricingClient.GetPlan(organization.PlanType);
return new OrganizationResponseModel(organization, plan);
var plan = await _pricingClient.GetPlan(updatedOrganization.PlanType);
return TypedResults.Ok(new OrganizationResponseModel(updatedOrganization, plan));
}
[HttpPost("{id}")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
public async Task<OrganizationResponseModel> PostPut(string id, [FromBody] OrganizationUpdateRequestModel model)
public async Task<IResult> PostPut(Guid id, [FromBody] OrganizationUpdateRequestModel model)
{
return await Put(id, model);
}
@@ -588,11 +585,4 @@ public class OrganizationsController : Controller
return organization.PlanType;
}
private bool ShouldUpdateBilling(OrganizationUpdateRequestModel model, Organization organization)
{
var organizationNameChanged = model.Name != organization.Name;
var billingEmailChanged = model.BillingEmail != organization.BillingEmail;
return !_globalSettings.SelfHosted && (organizationNameChanged || billingEmailChanged);
}
}

View File

@@ -12,6 +12,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Context;
@@ -41,8 +42,9 @@ public class PoliciesController : Controller
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IPolicyRepository _policyRepository;
private readonly IUserService _userService;
private readonly IFeatureService _featureService;
private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
public PoliciesController(IPolicyRepository policyRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -53,7 +55,9 @@ public class PoliciesController : Controller
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
IOrganizationRepository organizationRepository,
ISavePolicyCommand savePolicyCommand)
IFeatureService featureService,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand)
{
_policyRepository = policyRepository;
_organizationUserRepository = organizationUserRepository;
@@ -65,7 +69,9 @@ public class PoliciesController : Controller
_organizationRepository = organizationRepository;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_featureService = featureService;
_savePolicyCommand = savePolicyCommand;
_vNextSavePolicyCommand = vNextSavePolicyCommand;
}
[HttpGet("{type}")]
@@ -203,27 +209,22 @@ public class PoliciesController : Controller
throw new NotFoundException();
}
if (type != model.Type)
{
throw new BadRequestException("Mismatched policy type");
}
var policyUpdate = await model.ToPolicyUpdateAsync(orgId, _currentContext);
var policyUpdate = await model.ToPolicyUpdateAsync(orgId, type, _currentContext);
var policy = await _savePolicyCommand.SaveAsync(policyUpdate);
return new PolicyResponseModel(policy);
}
[HttpPut("{type}/vnext")]
[RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)]
[Authorize<ManagePoliciesRequirement>]
public async Task<PolicyResponseModel> PutVNext(Guid orgId, [FromBody] SavePolicyRequest model)
public async Task<PolicyResponseModel> PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model)
{
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, _currentContext);
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext);
var policy = await _savePolicyCommand.VNextSaveAsync(savePolicyRequest);
var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ?
await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) :
await _savePolicyCommand.VNextSaveAsync(savePolicyRequest);
return new PolicyResponseModel(policy);
}
}

View File

@@ -1,6 +1,5 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Context;
@@ -8,13 +7,11 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations")]
[Authorize("Application")]
public class SlackIntegrationController(

View File

@@ -1,6 +1,5 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Context;
@@ -8,7 +7,6 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Bot.Builder;
@@ -16,7 +14,6 @@ using Microsoft.Bot.Builder.Integration.AspNet.Core;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations")]
[Authorize("Application")]
public class TeamsIntegrationController(

View File

@@ -1,41 +1,28 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Data;
using Bit.Core.Settings;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationUpdateRequestModel
{
[Required]
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string BusinessName { get; set; }
[EmailAddress]
[Required]
[StringLength(256)]
public string BillingEmail { get; set; }
public Permissions Permissions { get; set; }
public OrganizationKeysRequestModel Keys { get; set; }
public string? Name { get; set; }
public virtual Organization ToOrganization(Organization existingOrganization, GlobalSettings globalSettings)
[EmailAddress]
[StringLength(256)]
public string? BillingEmail { get; set; }
public OrganizationKeysRequestModel? Keys { get; set; }
public OrganizationUpdateRequest ToCommandRequest(Guid organizationId) => new()
{
if (!globalSettings.SelfHosted)
{
// These items come from the license file
existingOrganization.Name = Name;
existingOrganization.BusinessName = BusinessName;
existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim();
}
Keys?.ToOrganization(existingOrganization);
return existingOrganization;
}
OrganizationId = organizationId,
Name = Name,
BillingEmail = BillingEmail,
PublicKey = Keys?.PublicKey,
EncryptedPrivateKey = Keys?.EncryptedPrivateKey
};
}

View File

@@ -9,20 +9,18 @@ namespace Bit.Api.AdminConsole.Models.Request;
public class PolicyRequestModel
{
[Required]
public PolicyType? Type { get; set; }
[Required]
public bool? Enabled { get; set; }
public Dictionary<string, object>? Data { get; set; }
public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext)
public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext)
{
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, Type!.Value);
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type);
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
return new()
{
Type = Type!.Value,
Type = type,
OrganizationId = organizationId,
Data = serializedData,
Enabled = Enabled.GetValueOrDefault(),

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Utilities;
@@ -13,10 +14,10 @@ public class SavePolicyRequest
public Dictionary<string, object>? Metadata { get; set; }
public async Task<SavePolicyModel> ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext)
public async Task<SavePolicyModel> ToSavePolicyModelAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext)
{
var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, currentContext);
var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, Policy.Type!.Value);
var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, type, currentContext);
var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, type);
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
return new SavePolicyModel(policyUpdate, performedBy, metadata);

View File

@@ -2,8 +2,6 @@
using Bit.Core.Enums;
using Bit.Core.Models.Api;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
@@ -11,8 +9,6 @@ public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration")
: base(obj)
{
ArgumentNullException.ThrowIfNull(organizationIntegrationConfiguration);
Id = organizationIntegrationConfiguration.Id;
Configuration = organizationIntegrationConfiguration.Configuration;
CreationDate = organizationIntegrationConfiguration.CreationDate;

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

@@ -1,6 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable

using System.Net;
using Bit.Api.Models.Public.Request;
using Bit.Api.Models.Public.Response;
@@ -8,6 +6,7 @@ using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core.Context;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization;
@@ -22,6 +21,9 @@ public class EventsController : Controller
private readonly IEventRepository _eventRepository;
private readonly ICipherRepository _cipherRepository;
private readonly ICurrentContext _currentContext;
private readonly ISecretRepository _secretRepository;
private readonly IProjectRepository _projectRepository;
private readonly IUserService _userService;
private readonly ILogger<EventsController> _logger;
private readonly IFeatureService _featureService;
@@ -29,12 +31,18 @@ public class EventsController : Controller
IEventRepository eventRepository,
ICipherRepository cipherRepository,
ICurrentContext currentContext,
ISecretRepository secretRepository,
IProjectRepository projectRepository,
IUserService userService,
ILogger<EventsController> logger,
IFeatureService featureService)
{
_eventRepository = eventRepository;
_cipherRepository = cipherRepository;
_currentContext = currentContext;
_secretRepository = secretRepository;
_projectRepository = projectRepository;
_userService = userService;
_logger = logger;
_featureService = featureService;
}
@@ -50,35 +58,76 @@ public class EventsController : Controller
[ProducesResponseType(typeof(PagedListResponseModel<EventResponseModel>), (int)HttpStatusCode.OK)]
public async Task<IActionResult> List([FromQuery] EventFilterRequestModel request)
{
if (!_currentContext.OrganizationId.HasValue)
{
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
}
var organizationId = _currentContext.OrganizationId.Value;
var dateRange = request.ToDateRange();
var result = new PagedResult<IEvent>();
if (request.ActingUserId.HasValue)
{
result = await _eventRepository.GetManyByOrganizationActingUserAsync(
_currentContext.OrganizationId.Value, request.ActingUserId.Value, dateRange.Item1, dateRange.Item2,
organizationId, request.ActingUserId.Value, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
else if (request.ItemId.HasValue)
{
var cipher = await _cipherRepository.GetByIdAsync(request.ItemId.Value);
if (cipher != null && cipher.OrganizationId == _currentContext.OrganizationId.Value)
if (cipher != null && cipher.OrganizationId == organizationId)
{
result = await _eventRepository.GetManyByCipherAsync(
cipher, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
}
else if (request.SecretId.HasValue)
{
var secret = await _secretRepository.GetByIdAsync(request.SecretId.Value);
if (secret == null)
{
secret = new Core.SecretsManager.Entities.Secret { Id = request.SecretId.Value, OrganizationId = organizationId };
}
if (secret.OrganizationId == organizationId)
{
result = await _eventRepository.GetManyBySecretAsync(
secret, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
else
{
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
}
}
else if (request.ProjectId.HasValue)
{
var project = await _projectRepository.GetByIdAsync(request.ProjectId.Value);
if (project != null && project.OrganizationId == organizationId)
{
result = await _eventRepository.GetManyByProjectAsync(
project, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
else
{
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
}
}
else
{
result = await _eventRepository.GetManyByOrganizationAsync(
_currentContext.OrganizationId.Value, dateRange.Item1, dateRange.Item2,
organizationId, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
var eventResponses = result.Data.Select(e => new EventResponseModel(e));
var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken);
var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken ?? "");
_logger.LogAggregateData(_featureService, organizationId, response, request);
_logger.LogAggregateData(_featureService, _currentContext.OrganizationId!.Value, response, request);
return new JsonResult(response);
}
}

View File

@@ -5,11 +5,15 @@ using System.Net;
using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Api.Models.Public.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -22,18 +26,24 @@ public class PoliciesController : Controller
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyService _policyService;
private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
public PoliciesController(
IPolicyRepository policyRepository,
IPolicyService policyService,
ICurrentContext currentContext,
ISavePolicyCommand savePolicyCommand)
IFeatureService featureService,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand)
{
_policyRepository = policyRepository;
_policyService = policyService;
_currentContext = currentContext;
_featureService = featureService;
_savePolicyCommand = savePolicyCommand;
_vNextSavePolicyCommand = vNextSavePolicyCommand;
}
/// <summary>
@@ -87,8 +97,17 @@ public class PoliciesController : Controller
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Put(PolicyType type, [FromBody] PolicyUpdateRequestModel model)
{
var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type);
var policy = await _savePolicyCommand.SaveAsync(policyUpdate);
Policy policy;
if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor))
{
var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type);
policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel);
}
else
{
var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type);
policy = await _savePolicyCommand.SaveAsync(policyUpdate);
}
var response = new PolicyResponseModel(policy);
return new JsonResult(response);

View File

@@ -24,6 +24,14 @@ public class EventFilterRequestModel
/// </summary>
public Guid? ItemId { get; set; }
/// <summary>
/// The unique identifier of the related secret that the event describes.
/// </summary>
public Guid? SecretId { get; set; }
/// <summary>
/// The unique identifier of the related project that the event describes.
/// </summary>
public Guid? ProjectId { get; set; }
/// <summary>
/// A cursor for use in pagination.
/// </summary>
public string ContinuationToken { get; set; }

View File

@@ -8,6 +8,8 @@ namespace Bit.Api.AdminConsole.Public.Models.Request;
public class PolicyUpdateRequestModel : PolicyBaseModel
{
public Dictionary<string, object>? Metadata { get; set; }
public PolicyUpdate ToPolicyUpdate(Guid organizationId, PolicyType type)
{
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type);
@@ -21,4 +23,22 @@ public class PolicyUpdateRequestModel : PolicyBaseModel
PerformedBy = new SystemUser(EventSystemUser.PublicApi)
};
}
public SavePolicyModel ToSavePolicyModel(Guid organizationId, PolicyType type)
{
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type);
var policyUpdate = new PolicyUpdate
{
Type = type,
OrganizationId = organizationId,
Data = serializedData,
Enabled = Enabled.GetValueOrDefault()
};
var performedBy = new SystemUser(EventSystemUser.PublicApi);
var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, type);
return new SavePolicyModel(policyUpdate, performedBy, metadata);
}
}

View File

@@ -4,6 +4,7 @@ using Bit.Api.Models.Request;
using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Business;
@@ -24,7 +25,8 @@ namespace Bit.Api.Billing.Controllers;
public class AccountsController(
IUserService userService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserAccountKeysQuery userAccountKeysQuery) : Controller
IUserAccountKeysQuery userAccountKeysQuery,
IFeatureService featureService) : Controller
{
[HttpPost("premium")]
public async Task<PaymentResponseModel> PostPremiumAsync(
@@ -84,16 +86,24 @@ public class AccountsController(
throw new UnauthorizedAccessException();
}
if (!globalSettings.SelfHosted && user.Gateway != null)
// Only cloud-hosted users with payment gateways have subscription and discount information
if (!globalSettings.SelfHosted)
{
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
return new SubscriptionResponseModel(user, subscriptionInfo, license);
}
else if (!globalSettings.SelfHosted)
{
var license = await userService.GenerateLicenseAsync(user);
return new SubscriptionResponseModel(user, license);
if (user.Gateway != null)
{
// Note: PM23341_Milestone_2 is the feature flag for the overall Milestone 2 initiative (PM-23341).
// This specific implementation (PM-26682) adds discount display functionality as part of that initiative.
// The feature flag controls the broader Milestone 2 feature set, not just this specific task.
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount);
}
else
{
var license = await userService.GenerateLicenseAsync(user);
return new SubscriptionResponseModel(user, license);
}
}
else
{

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

@@ -1,6 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Models.Api;
@@ -11,7 +9,17 @@ namespace Bit.Api.Models.Response;
public class SubscriptionResponseModel : ResponseModel
{
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license)
/// <param name="user">The user entity containing storage and premium subscription information</param>
/// <param name="subscription">Subscription information retrieved from the payment provider (Stripe/Braintree)</param>
/// <param name="license">The user's license containing expiration and feature entitlements</param>
/// <param name="includeMilestone2Discount">
/// Whether to include discount information in the response.
/// Set to true when the PM23341_Milestone_2 feature flag is enabled AND
/// you want to expose Milestone 2 discount information to the client.
/// The discount will only be included if it matches the specific Milestone 2 coupon ID.
/// </param>
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license, bool includeMilestone2Discount = false)
: base("subscription")
{
Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
@@ -22,9 +30,14 @@ public class SubscriptionResponseModel : ResponseModel
MaxStorageGb = user.MaxStorageGb;
License = license;
Expiration = License.Expires;
// Only display the Milestone 2 subscription discount on the subscription page.
CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription.CustomerDiscount)
? new BillingCustomerDiscount(subscription.CustomerDiscount!)
: null;
}
public SubscriptionResponseModel(User user, UserLicense license = null)
public SubscriptionResponseModel(User user, UserLicense? license = null)
: base("subscription")
{
StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null;
@@ -38,21 +51,109 @@ public class SubscriptionResponseModel : ResponseModel
}
}
public string StorageName { get; set; }
public string? StorageName { get; set; }
public double? StorageGb { get; set; }
public short? MaxStorageGb { get; set; }
public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; }
public BillingSubscription Subscription { get; set; }
public UserLicense License { get; set; }
public BillingSubscriptionUpcomingInvoice? UpcomingInvoice { get; set; }
public BillingSubscription? Subscription { get; set; }
/// <summary>
/// Customer discount information from Stripe for the Milestone 2 subscription discount.
/// Only includes the specific Milestone 2 coupon (cm3nHfO1) when it's a perpetual discount (no expiration).
/// This is for display purposes only and does not affect Stripe's automatic discount application.
/// Other discounts may still apply in Stripe billing but are not included in this response.
/// <para>
/// Null when:
/// - The PM23341_Milestone_2 feature flag is disabled
/// - There is no active discount
/// - The discount coupon ID doesn't match the Milestone 2 coupon (cm3nHfO1)
/// - The instance is self-hosted
/// </para>
/// </summary>
public BillingCustomerDiscount? CustomerDiscount { get; set; }
public UserLicense? License { get; set; }
public DateTime? Expiration { get; set; }
/// <summary>
/// Determines whether the Milestone 2 discount should be included in the response.
/// </summary>
/// <param name="includeMilestone2Discount">Whether the feature flag is enabled and discount should be considered.</param>
/// <param name="customerDiscount">The customer discount from subscription info, if any.</param>
/// <returns>True if the discount should be included; false otherwise.</returns>
private static bool ShouldIncludeMilestone2Discount(
bool includeMilestone2Discount,
SubscriptionInfo.BillingCustomerDiscount? customerDiscount)
{
return includeMilestone2Discount &&
customerDiscount != null &&
customerDiscount.Id == StripeConstants.CouponIDs.Milestone2SubscriptionDiscount &&
customerDiscount.Active;
}
}
public class BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
/// <summary>
/// Customer discount information from Stripe billing.
/// </summary>
public class BillingCustomerDiscount
{
public string Id { get; } = discount.Id;
public bool Active { get; } = discount.Active;
public decimal? PercentOff { get; } = discount.PercentOff;
public List<string> AppliesTo { get; } = discount.AppliesTo;
/// <summary>
/// The Stripe coupon ID (e.g., "cm3nHfO1").
/// </summary>
public string? Id { get; }
/// <summary>
/// Whether the discount is a recurring/perpetual discount with no expiration date.
/// <para>
/// This property is true only when the discount has no end date, meaning it applies
/// indefinitely to all future renewals. This is a product decision for Milestone 2
/// to only display perpetual discounts in the UI.
/// </para>
/// <para>
/// Note: This does NOT indicate whether the discount is "currently active" in the billing sense.
/// A discount with a future end date is functionally active and will be applied by Stripe,
/// but this property will be false because it has an expiration date.
/// </para>
/// </summary>
public bool Active { get; }
/// <summary>
/// Percentage discount applied to the subscription (e.g., 20.0 for 20% off).
/// Null if this is an amount-based discount.
/// </summary>
public decimal? PercentOff { get; }
/// <summary>
/// Fixed amount discount in USD (e.g., 14.00 for $14 off).
/// Converted from Stripe's cent-based values (1400 cents → $14.00).
/// Null if this is a percentage-based discount.
/// Note: Stripe stores amounts in the smallest currency unit. This value is always in USD.
/// </summary>
public decimal? AmountOff { get; }
/// <summary>
/// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]).
/// <para>
/// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe).
/// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe).
/// Non-empty list: discount applies only to the specified product IDs.
/// </para>
/// </summary>
public IReadOnlyList<string>? AppliesTo { get; }
/// <summary>
/// Creates a BillingCustomerDiscount from a SubscriptionInfo.BillingCustomerDiscount.
/// </summary>
/// <param name="discount">The discount to convert. Must not be null.</param>
/// <exception cref="ArgumentNullException">Thrown when discount is null.</exception>
public BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
{
ArgumentNullException.ThrowIfNull(discount);
Id = discount.Id;
Active = discount.Active;
PercentOff = discount.PercentOff;
AmountOff = discount.AmountOff;
AppliesTo = discount.AppliesTo;
}
}
public class BillingSubscription
@@ -83,10 +184,10 @@ public class BillingSubscription
public DateTime? PeriodEndDate { get; set; }
public DateTime? CancelledDate { get; set; }
public bool CancelAtEndDate { get; set; }
public string Status { get; set; }
public string? Status { get; set; }
public bool Cancelled { get; set; }
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
public string CollectionMethod { get; set; }
public string? CollectionMethod { get; set; }
public DateTime? SuspensionDate { get; set; }
public DateTime? UnpaidPeriodEndDate { get; set; }
public int? GracePeriod { get; set; }
@@ -104,11 +205,11 @@ public class BillingSubscription
AddonSubscriptionItem = item.AddonSubscriptionItem;
}
public string ProductId { get; set; }
public string Name { get; set; }
public string? ProductId { get; set; }
public string? Name { get; set; }
public decimal Amount { get; set; }
public int Quantity { get; set; }
public string Interval { get; set; }
public string? Interval { get; set; }
public bool SponsoredSubscriptionItem { get; set; }
public bool AddonSubscriptionItem { get; set; }
}

View File

@@ -1,9 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using AspNetCoreRateLimit;
using Bit.Core.Utilities;
using Microsoft.IdentityModel.Tokens;
using Bit.Core.Utilities;
namespace Bit.Api;
@@ -17,32 +12,8 @@ public class Program
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Exception != null &&
(e.Exception.GetType() == typeof(SecurityTokenValidationException) ||
e.Exception.Message == "Bad security stamp."))
{
return false;
}
if (
context.Contains(typeof(IpRateLimitMiddleware).FullName))
{
return e.Level >= globalSettings.MinLogLevel.ApiSettings.IpRateLimit;
}
if (context.Contains("Duende.IdentityServer.Validation.TokenValidator") ||
context.Contains("Duende.IdentityServer.Validation.TokenRequestValidator"))
{
return e.Level >= globalSettings.MinLogLevel.ApiSettings.IdentityToken;
}
return e.Level >= globalSettings.MinLogLevel.ApiSettings.Default;
}));
})
.AddSerilogFileLogging()
.Build()
.Run();
}

View File

@@ -234,12 +234,10 @@ public class Startup
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings,
ILogger<Startup> logger)
{
IdentityModelEventSource.ShowPII = true;
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

View File

@@ -402,8 +402,9 @@ public class CiphersController : Controller
{
var org = _currentContext.GetOrganization(organizationId);
// If we're not an "admin" or if we're not a provider user we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
// If we're not an "admin" we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.EditAnyCollection: true }))
{
return false;
}
@@ -416,8 +417,9 @@ public class CiphersController : Controller
{
var org = _currentContext.GetOrganization(organizationId);
// If we're not an "admin" or if we're a provider user we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
// If we're not an "admin" we don't need to check the ciphers
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
{ Permissions.EditAnyCollection: true }))
{
return false;
}
@@ -1420,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);
});
}
@@ -1523,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

@@ -32,9 +32,6 @@
"send": {
"connectionString": "SECRET"
},
"sentry": {
"dsn": "SECRET"
},
"notificationHub": {
"connectionString": "SECRET",
"hubName": "SECRET"

View File

@@ -158,6 +158,7 @@ public class FreshsalesController : Controller
planName = "Free";
return true;
case PlanType.FamiliesAnnually:
case PlanType.FamiliesAnnually2025:
case PlanType.FamiliesAnnually2019:
planName = "Families";
return true;

View File

@@ -0,0 +1,36 @@
using Bit.Billing.Jobs;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Billing.Controllers;
[Route("jobs")]
[SelfHosted(NotSelfHostedOnly = true)]
[RequireLowerEnvironment]
public class JobsController(
JobsHostedService jobsHostedService) : Controller
{
[HttpPost("run/{jobName}")]
public async Task<IActionResult> RunJobAsync(string jobName)
{
if (jobName == nameof(ReconcileAdditionalStorageJob))
{
await jobsHostedService.RunJobAdHocAsync<ReconcileAdditionalStorageJob>();
return Ok(new { message = $"Job {jobName} scheduled successfully" });
}
return BadRequest(new { error = $"Unknown job name: {jobName}" });
}
[HttpPost("stop/{jobName}")]
public async Task<IActionResult> StopJobAsync(string jobName)
{
if (jobName == nameof(ReconcileAdditionalStorageJob))
{
await jobsHostedService.InterruptAdHocJobAsync<ReconcileAdditionalStorageJob>();
return Ok(new { message = $"Job {jobName} queued for cancellation" });
}
return BadRequest(new { error = $"Unknown job name: {jobName}" });
}
}

View File

@@ -10,4 +10,13 @@ public class AliveJob(ILogger<AliveJob> logger) : BaseJob(logger)
_logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Billing service is alive!");
return Task.FromResult(0);
}
public static ITrigger GetTrigger()
{
return TriggerBuilder.Create()
.WithIdentity("EveryTopOfTheHourTrigger")
.StartNow()
.WithCronSchedule("0 0 * * * ?")
.Build();
}
}

View File

@@ -1,29 +1,27 @@
using Bit.Core.Jobs;
using Bit.Core.Exceptions;
using Bit.Core.Jobs;
using Bit.Core.Settings;
using Quartz;
namespace Bit.Billing.Jobs;
public class JobsHostedService : BaseJobsHostedService
public class JobsHostedService(
GlobalSettings globalSettings,
IServiceProvider serviceProvider,
ILogger<JobsHostedService> logger,
ILogger<JobListener> listenerLogger,
ISchedulerFactory schedulerFactory)
: BaseJobsHostedService(globalSettings, serviceProvider, logger, listenerLogger)
{
public JobsHostedService(
GlobalSettings globalSettings,
IServiceProvider serviceProvider,
ILogger<JobsHostedService> logger,
ILogger<JobListener> listenerLogger)
: base(globalSettings, serviceProvider, logger, listenerLogger) { }
private List<JobKey> AdHocJobKeys { get; } = [];
private IScheduler? _adHocScheduler;
public override async Task StartAsync(CancellationToken cancellationToken)
{
var everyTopOfTheHourTrigger = TriggerBuilder.Create()
.WithIdentity("EveryTopOfTheHourTrigger")
.StartNow()
.WithCronSchedule("0 0 * * * ?")
.Build();
Jobs = new List<Tuple<Type, ITrigger>>
{
new Tuple<Type, ITrigger>(typeof(AliveJob), everyTopOfTheHourTrigger)
new(typeof(AliveJob), AliveJob.GetTrigger()),
new(typeof(ReconcileAdditionalStorageJob), ReconcileAdditionalStorageJob.GetTrigger())
};
await base.StartAsync(cancellationToken);
@@ -33,5 +31,54 @@ public class JobsHostedService : BaseJobsHostedService
{
services.AddTransient<AliveJob>();
services.AddTransient<SubscriptionCancellationJob>();
services.AddTransient<ReconcileAdditionalStorageJob>();
// add this service as a singleton so we can inject it where needed
services.AddSingleton<JobsHostedService>();
services.AddHostedService(sp => sp.GetRequiredService<JobsHostedService>());
}
public async Task InterruptAdHocJobAsync<T>(CancellationToken cancellationToken = default) where T : class, IJob
{
if (_adHocScheduler == null)
{
throw new InvalidOperationException("AdHocScheduler is null, cannot interrupt ad-hoc job.");
}
var jobKey = AdHocJobKeys.FirstOrDefault(j => j.Name == typeof(T).ToString());
if (jobKey == null)
{
throw new NotFoundException($"Cannot find job key: {typeof(T)}, not running?");
}
logger.LogInformation("CANCELLING ad-hoc job with key: {JobKey}", jobKey);
AdHocJobKeys.Remove(jobKey);
await _adHocScheduler.Interrupt(jobKey, cancellationToken);
}
public async Task RunJobAdHocAsync<T>(CancellationToken cancellationToken = default) where T : class, IJob
{
_adHocScheduler ??= await schedulerFactory.GetScheduler(cancellationToken);
var jobKey = new JobKey(typeof(T).ToString());
var currentlyExecuting = await _adHocScheduler.GetCurrentlyExecutingJobs(cancellationToken);
if (currentlyExecuting.Any(j => j.JobDetail.Key.Equals(jobKey)))
{
throw new InvalidOperationException($"Job {jobKey} is already running");
}
AdHocJobKeys.Add(jobKey);
var job = JobBuilder.Create<T>()
.WithIdentity(jobKey)
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity(typeof(T).ToString())
.StartNow()
.Build();
logger.LogInformation("Scheduling ad-hoc job with key: {JobKey}", jobKey);
await _adHocScheduler.ScheduleJob(job, trigger, cancellationToken);
}
}

View File

@@ -0,0 +1,207 @@
using System.Globalization;
using System.Text.Json;
using Bit.Billing.Services;
using Bit.Core;
using Bit.Core.Billing.Constants;
using Bit.Core.Jobs;
using Bit.Core.Services;
using Quartz;
using Stripe;
namespace Bit.Billing.Jobs;
public class ReconcileAdditionalStorageJob(
IStripeFacade stripeFacade,
ILogger<ReconcileAdditionalStorageJob> logger,
IFeatureService featureService) : BaseJob(logger)
{
private const string _storageGbMonthlyPriceId = "storage-gb-monthly";
private const string _storageGbAnnuallyPriceId = "storage-gb-annually";
private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually";
private const int _storageGbToRemove = 4;
protected override async Task ExecuteJobAsync(IJobExecutionContext context)
{
if (!featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob))
{
logger.LogInformation("Skipping ReconcileAdditionalStorageJob, feature flag off.");
return;
}
var liveMode = featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode);
// Execution tracking
var subscriptionsFound = 0;
var subscriptionsUpdated = 0;
var subscriptionsWithErrors = 0;
var failures = new List<string>();
logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode);
var priceIds = new[] { _storageGbMonthlyPriceId, _storageGbAnnuallyPriceId, _personalStorageGbAnnuallyPriceId };
foreach (var priceId in priceIds)
{
var options = new SubscriptionListOptions
{
Limit = 100,
Status = StripeConstants.SubscriptionStatus.Active,
Price = priceId
};
await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options))
{
if (context.CancellationToken.IsCancellationRequested)
{
logger.LogWarning(
"Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " +
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
subscriptionsFound,
liveMode
? subscriptionsUpdated
: $"(In live mode, would have updated) {subscriptionsUpdated}",
subscriptionsWithErrors,
failures.Count > 0
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
: string.Empty
);
return;
}
if (subscription == null)
{
continue;
}
logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id);
subscriptionsFound++;
if (subscription.Metadata?.TryGetValue(StripeConstants.MetadataKeys.StorageReconciled2025, out var dateString) == true)
{
if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out var dateProcessed))
{
logger.LogInformation("Skipping subscription {SubscriptionId} - already processed on {Date}",
subscription.Id,
dateProcessed.ToString("f"));
continue;
}
}
var updateOptions = BuildSubscriptionUpdateOptions(subscription, priceId);
if (updateOptions == null)
{
logger.LogInformation("Skipping subscription {SubscriptionId} - no updates needed", subscription.Id);
continue;
}
subscriptionsUpdated++;
if (!liveMode)
{
logger.LogInformation(
"Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}",
subscription.Id,
Environment.NewLine,
JsonSerializer.Serialize(updateOptions));
continue;
}
try
{
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id);
}
catch (Exception ex)
{
subscriptionsWithErrors++;
failures.Add($"Subscription {subscription.Id}: {ex.Message}");
logger.LogError(ex, "Failed to update subscription {SubscriptionId}: {ErrorMessage}",
subscription.Id, ex.Message);
}
}
}
logger.LogInformation(
"ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " +
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
subscriptionsFound,
liveMode
? subscriptionsUpdated
: $"(In live mode, would have updated) {subscriptionsUpdated}",
subscriptionsWithErrors,
failures.Count > 0
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
: string.Empty
);
}
private SubscriptionUpdateOptions? BuildSubscriptionUpdateOptions(
Subscription subscription,
string targetPriceId)
{
if (subscription.Items?.Data == null)
{
return null;
}
var updateOptions = new SubscriptionUpdateOptions
{
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o")
},
Items = []
};
var hasUpdates = false;
foreach (var item in subscription.Items.Data.Where(item => item?.Price?.Id == targetPriceId))
{
hasUpdates = true;
var currentQuantity = item.Quantity;
if (currentQuantity > _storageGbToRemove)
{
var newQuantity = currentQuantity - _storageGbToRemove;
logger.LogInformation(
"Subscription {SubscriptionId}: reducing quantity from {CurrentQuantity} to {NewQuantity} for price {PriceId}",
subscription.Id,
currentQuantity,
newQuantity,
item.Price.Id);
updateOptions.Items.Add(new SubscriptionItemOptions
{
Id = item.Id,
Quantity = newQuantity
});
}
else
{
logger.LogInformation("Subscription {SubscriptionId}: deleting storage item with quantity {CurrentQuantity} for price {PriceId}",
subscription.Id,
currentQuantity,
item.Price.Id);
updateOptions.Items.Add(new SubscriptionItemOptions
{
Id = item.Id,
Deleted = true
});
}
}
return hasUpdates ? updateOptions : null;
}
public static ITrigger GetTrigger()
{
return TriggerBuilder.Create()
.WithIdentity("EveryMorningTrigger")
.StartNow()
.WithCronSchedule("0 0 16 * * ?") // 10am CST daily; the pods execute in UTC time
.Build();
}
}

View File

@@ -11,25 +11,8 @@ public class Program
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (context.StartsWith("\"Bit.Billing.Jobs") || context.StartsWith("\"Bit.Core.Jobs"))
{
return e.Level >= globalSettings.MinLogLevel.BillingSettings.Jobs;
}
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
{
return false;
}
return e.Level >= globalSettings.MinLogLevel.BillingSettings.Default;
}));
})
.AddSerilogFileLogging()
.Build()
.Run();
}

View File

@@ -78,6 +78,11 @@ public interface IStripeFacade
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
IAsyncEnumerable<Subscription> ListSubscriptionsAutoPagingAsync(
SubscriptionListOptions options = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Subscription> GetSubscription(
string subscriptionId,
SubscriptionGetOptions subscriptionGetOptions = null,

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

@@ -98,6 +98,12 @@ public class StripeFacade : IStripeFacade
CancellationToken cancellationToken = default) =>
await _subscriptionService.ListAsync(options, requestOptions, cancellationToken);
public IAsyncEnumerable<Subscription> ListSubscriptionsAutoPagingAsync(
SubscriptionListOptions options = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
_subscriptionService.ListAutoPagingAsync(options, requestOptions, cancellationToken);
public async Task<Subscription> GetSubscription(
string subscriptionId,
SubscriptionGetOptions subscriptionGetOptions = null,

View File

@@ -1,7 +1,5 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Globalization;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
@@ -10,14 +8,21 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Entities;
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Stripe;
using Event = Stripe.Event;
using Plan = Bit.Core.Models.StaticStore.Plan;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
namespace Bit.Billing.Services.Implementations;
using static StripeConstants;
public class UpcomingInvoiceHandler(
IGetPaymentMethodQuery getPaymentMethodQuery,
ILogger<StripeEventProcessor> logger,
@@ -29,7 +34,9 @@ public class UpcomingInvoiceHandler(
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IUserRepository userRepository,
IValidateSponsorshipCommand validateSponsorshipCommand)
IValidateSponsorshipCommand validateSponsorshipCommand,
IMailer mailer,
IFeatureService featureService)
: IUpcomingInvoiceHandler
{
public async Task HandleAsync(Event parsedEvent)
@@ -37,7 +44,8 @@ public class UpcomingInvoiceHandler(
var invoice = await stripeEventService.GetInvoice(parsedEvent);
var customer =
await stripeFacade.GetCustomer(invoice.CustomerId, new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] });
await stripeFacade.GetCustomer(invoice.CustomerId,
new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] });
var subscription = customer.Subscriptions.FirstOrDefault();
@@ -50,116 +58,436 @@ public class UpcomingInvoiceHandler(
if (organizationId.HasValue)
{
var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
if (organization == null)
{
return;
}
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
if (!plan.IsAnnual)
{
return;
}
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
{
var sponsorshipIsValid = await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
if (!sponsorshipIsValid)
{
/*
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
* price. Given that this is the case, we need the new invoice amount
*/
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
}
}
await SendUpcomingInvoiceEmailsAsync(new List<string> { organization.BillingEmail }, invoice);
/*
* TODO: https://bitwarden.atlassian.net/browse/PM-4862
* Disabling this as part of a hot fix. It needs to check whether the organization
* belongs to a Reseller provider and only send an email to the organization owners if it does.
* It also requires a new email template as the current one contains too much billing information.
*/
// var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id);
// await SendEmails(ownerEmails);
await HandleOrganizationUpcomingInvoiceAsync(
organizationId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
else if (userId.HasValue)
{
var user = await userRepository.GetByIdAsync(userId.Value);
if (user == null)
{
return;
}
if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation())
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
user.Id,
parsedEvent.Id);
}
}
if (user.Premium)
{
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
}
await HandlePremiumUsersUpcomingInvoiceAsync(
userId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
else if (providerId.HasValue)
{
var provider = await providerRepository.GetByIdAsync(providerId.Value);
await HandleProviderUpcomingInvoiceAsync(
providerId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
}
if (provider == null)
#region Organizations
private async Task HandleOrganizationUpcomingInvoiceAsync(
Guid organizationId,
Event @event,
Invoice invoice,
Customer customer,
Subscription subscription)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
logger.LogWarning("Could not find Organization ({OrganizationID}) for '{EventType}' event ({EventID})",
organizationId, @event.Type, @event.Id);
return;
}
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, @event.Id);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3);
var subscriptionAligned = await AlignOrganizationSubscriptionConcernsAsync(
organization,
@event,
subscription,
plan,
milestone3);
/*
* Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue
* with processing.
*/
if (subscriptionAligned)
{
return;
}
// Don't send the upcoming invoice email unless the organization's on an annual plan.
if (!plan.IsAnnual)
{
return;
}
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
{
var sponsorshipIsValid =
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId);
if (!sponsorshipIsValid)
{
/*
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
* price. Given that this is the case, we need the new invoice amount
*/
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
}
}
await SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice);
}
private async Task AlignOrganizationTaxConcernsAsync(
Organization organization,
Subscription subscription,
Customer customer,
string eventId)
{
var nonUSBusinessUse =
organization.PlanType.GetProductTier() != ProductTierType.Families &&
customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
if (nonUSBusinessUse && customer.TaxExempt != TaxExempt.Reverse)
{
try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) to reverse tax exemption while processing event with ID {EventID}",
organization.Id,
eventId);
}
}
if (!subscription.AutomaticTax.Enabled)
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) subscription to automatic tax while processing event with ID {EventID}",
organization.Id,
eventId);
}
}
}
/// <summary>
/// Aligns the organization's subscription details with the specified plan and milestone requirements.
/// </summary>
/// <param name="organization">The organization whose subscription is being updated.</param>
/// <param name="event">The Stripe event associated with this operation.</param>
/// <param name="subscription">The organization's subscription.</param>
/// <param name="plan">The organization's current plan.</param>
/// <param name="milestone3">A flag indicating whether the third milestone is enabled.</param>
/// <returns>Whether the operation resulted in an updated subscription.</returns>
private async Task<bool> AlignOrganizationSubscriptionConcernsAsync(
Organization organization,
Event @event,
Subscription subscription,
Plan plan,
bool milestone3)
{
// 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))
{
return false;
}
var passwordManagerItem =
subscription.Items.FirstOrDefault(item => item.Price.Id == plan.PasswordManager.StripePlanId);
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 false;
}
var familiesPlan = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
organization.PlanType = familiesPlan.Type;
organization.Plan = familiesPlan.Name;
organization.UsersGetPremium = familiesPlan.UsersGetPremium;
organization.Seats = familiesPlan.PasswordManager.BaseSeats;
var options = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Price = familiesPlan.PasswordManager.StripePlanId
}
],
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);
if (premiumAccessAddOnItem != null)
{
options.Items.Add(new SubscriptionItemOptions
{
Id = premiumAccessAddOnItem.Id,
Deleted = true
});
}
var seatAddOnItem = subscription.Items.FirstOrDefault(item => item.Price.Id == "personal-org-seat-annually");
if (seatAddOnItem != null)
{
options.Items.Add(new SubscriptionItemOptions
{
Id = seatAddOnItem.Id,
Deleted = true
});
}
}
try
{
await organizationRepository.ReplaceAsync(organization);
await stripeFacade.UpdateSubscription(subscription.Id, options);
await SendFamiliesRenewalEmailAsync(organization, familiesPlan);
return true;
}
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);
return false;
}
}
#endregion
#region Premium Users
private async Task HandlePremiumUsersUpcomingInvoiceAsync(
Guid userId,
Event @event,
Invoice invoice,
Customer customer,
Subscription subscription)
{
var user = await userRepository.GetByIdAsync(userId);
if (user == null)
{
logger.LogWarning("Could not find User ({UserID}) for '{EventType}' event ({EventID})",
userId, @event.Type, @event.Id);
return;
}
await AlignPremiumUsersTaxConcernsAsync(user, @event, customer, subscription);
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
if (milestone2Feature)
{
var subscriptionAligned = await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription);
/*
* Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue
* with processing.
*/
if (subscriptionAligned)
{
return;
}
await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id);
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId.Value);
}
}
private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var items = invoice.Lines.Select(i => i.Description).ToList();
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
if (user.Premium)
{
await mailService.SendInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
items,
true);
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
}
}
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice, Subscription subscription, Guid providerId)
private async Task AlignPremiumUsersTaxConcernsAsync(
User user,
Event @event,
Customer customer,
Subscription subscription)
{
if (!subscription.AutomaticTax.Enabled && customer.HasRecognizedTaxLocation())
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
user.Id,
@event.Id);
}
}
}
private async Task<bool> AlignPremiumUsersSubscriptionConcernsAsync(
User user,
Event @event,
Subscription subscription)
{
var premiumItem = subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually);
if (premiumItem == null)
{
logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})",
user.Id, @event.Type, @event.Id);
return false;
}
try
{
var plan = await pricingClient.GetAvailablePremiumPlan();
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions { Id = premiumItem.Id, Price = plan.Seat.StripePriceId }
],
Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }
],
ProrationBehavior = ProrationBehavior.None
});
await SendPremiumRenewalEmailAsync(user, plan);
return true;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}",
user.Id,
@event.Id);
return false;
}
}
#endregion
#region Providers
private async Task HandleProviderUpcomingInvoiceAsync(
Guid providerId,
Event @event,
Invoice invoice,
Customer customer,
Subscription subscription)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogWarning("Could not find Provider ({ProviderID}) for '{EventType}' event ({EventID})",
providerId, @event.Type, @event.Id);
return;
}
await AlignProviderTaxConcernsAsync(provider, subscription, customer, @event.Id);
if (!string.IsNullOrEmpty(provider.BillingEmail))
{
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId);
}
}
private async Task AlignProviderTaxConcernsAsync(
Provider provider,
Subscription subscription,
Customer customer,
string eventId)
{
if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
customer.TaxExempt != TaxExempt.Reverse)
{
try
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse });
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) to reverse tax exemption while processing event with ID {EventID}",
provider.Id,
eventId);
}
}
if (!subscription.AutomaticTax.Enabled)
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) subscription to automatic tax while processing event with ID {EventID}",
provider.Id,
eventId);
}
}
}
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice,
Subscription subscription, Guid providerId)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
@@ -195,96 +523,60 @@ public class UpcomingInvoiceHandler(
}
}
private async Task AlignOrganizationTaxConcernsAsync(
#endregion
#region Shared
private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var items = invoice.Lines.Select(i => i.Description).ToList();
if (invoice is { NextPaymentAttempt: not null, AmountDue: > 0 })
{
await mailService.SendInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
items,
true);
}
}
private async Task SendFamiliesRenewalEmailAsync(
Organization organization,
Subscription subscription,
Customer customer,
string eventId)
Plan familiesPlan)
{
var nonUSBusinessUse =
organization.PlanType.GetProductTier() != ProductTierType.Families &&
customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates;
if (nonUSBusinessUse && customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
var email = new Families2020RenewalMail
{
try
ToEmails = [organization.BillingEmail],
View = new Families2020RenewalMailView
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
MonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) to reverse tax exemption while processing event with ID {EventID}",
organization.Id,
eventId);
}
}
};
if (!subscription.AutomaticTax.Enabled)
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set organization's ({OrganizationID}) subscription to automatic tax while processing event with ID {EventID}",
organization.Id,
eventId);
}
}
await mailer.SendEmail(email);
}
private async Task AlignProviderTaxConcernsAsync(
Provider provider,
Subscription subscription,
Customer customer,
string eventId)
private async Task SendPremiumRenewalEmailAsync(
User user,
PremiumPlan premiumPlan)
{
if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates &&
customer.TaxExempt != StripeConstants.TaxExempt.Reverse)
/* TODO: Replace with proper premium renewal email template once finalized.
Using Families2020RenewalMail as a temporary stop-gap. */
var email = new Families2020RenewalMail
{
try
ToEmails = [user.Email],
View = new Families2020RenewalMailView
{
await stripeFacade.UpdateCustomer(subscription.CustomerId,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
MonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US"))
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) to reverse tax exemption while processing event with ID {EventID}",
provider.Id,
eventId);
}
}
};
if (!subscription.AutomaticTax.Enabled)
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set provider's ({ProviderID}) subscription to automatic tax while processing event with ID {EventID}",
provider.Id,
eventId);
}
}
await mailer.SendEmail(email);
}
#endregion
}

View File

@@ -10,7 +10,6 @@ using Bit.Core.Billing.Extensions;
using Bit.Core.Context;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.SecretsManager.Repositories.Noop;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -129,12 +128,8 @@ public class Startup
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings)
IWebHostEnvironment env)
{
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

View File

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

View File

@@ -30,9 +30,6 @@
"connectionString": "SECRET",
"applicationCacheTopicName": "SECRET"
},
"sentry": {
"dsn": "SECRET"
},
"notificationHub": {
"connectionString": "SECRET",
"hubName": "SECRET"

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

@@ -1,8 +1,8 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
@@ -36,13 +36,18 @@ public class IntegrationTemplateContext(EventMessage eventMessage)
public string DateIso8601 => Date.ToString("o");
public string EventMessage => JsonSerializer.Serialize(Event);
public User? User { get; set; }
public OrganizationUserUserDetails? User { get; set; }
public string? UserName => User?.Name;
public string? UserEmail => User?.Email;
public OrganizationUserType? UserType => User?.Type;
public User? ActingUser { get; set; }
public OrganizationUserUserDetails? ActingUser { get; set; }
public string? ActingUserName => ActingUser?.Name;
public string? ActingUserEmail => ActingUser?.Email;
public OrganizationUserType? ActingUserType => ActingUser?.Type;
public Group? Group { get; set; }
public string? GroupName => Group?.Name;
public Organization? Organization { get; set; }
public string? OrganizationName => Organization?.DisplayName();

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

@@ -33,6 +33,12 @@ public class SlackOAuthResponse : SlackApiResponse
public SlackTeam Team { get; set; } = new();
}
public class SlackSendMessageResponse : SlackApiResponse
{
[JsonPropertyName("channel")]
public string Channel { get; set; } = string.Empty;
}
public class SlackTeam
{
public string Id { get; set; } = string.Empty;

View File

@@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -24,7 +25,9 @@ public class VerifyOrganizationDomainCommand(
IEventService eventService,
IGlobalSettings globalSettings,
ICurrentContext currentContext,
IFeatureService featureService,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand,
IMailService mailService,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
@@ -131,15 +134,26 @@ public class VerifyOrganizationDomainCommand(
await SendVerifiedDomainUserEmailAsync(domain);
}
private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser) =>
await savePolicyCommand.SaveAsync(
new PolicyUpdate
{
OrganizationId = organizationId,
Type = PolicyType.SingleOrg,
Enabled = true,
PerformedBy = actingUser
});
private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser)
{
var policyUpdate = new PolicyUpdate
{
OrganizationId = organizationId,
Type = PolicyType.SingleOrg,
Enabled = true,
PerformedBy = actingUser
};
if (featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor))
{
var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser);
await vNextSavePolicyCommand.SaveAsync(savePolicyModel);
}
else
{
await savePolicyCommand.SaveAsync(policyUpdate);
}
}
private async Task SendVerifiedDomainUserEmailAsync(OrganizationDomain domain)
{

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

@@ -75,8 +75,7 @@ public class CloudOrganizationSignUpCommand(
PlanType = plan!.Type,
Seats = (short)(plan.PasswordManager.BaseSeats + signup.AdditionalSeats),
MaxCollections = plan.PasswordManager.MaxCollections,
MaxStorageGb = !plan.PasswordManager.BaseStorageGb.HasValue ?
(short?)null : (short)(plan.PasswordManager.BaseStorageGb.Value + signup.AdditionalStorageGb),
MaxStorageGb = (short)(plan.PasswordManager.BaseStorageGb + signup.AdditionalStorageGb),
UsePolicies = plan.HasPolicies,
UseSso = plan.HasSso,
UseGroups = plan.HasGroups,

View File

@@ -0,0 +1,15 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
public interface IOrganizationUpdateCommand
{
/// <summary>
/// Updates an organization's information in the Bitwarden database and Stripe (if required).
/// Also optionally updates an organization's public-private keypair if it was not created with one.
/// On self-host, only the public-private keys will be updated because all other properties are fixed by the license file.
/// </summary>
/// <param name="request">The update request containing the details to be updated.</param>
Task<Organization> UpdateAsync(OrganizationUpdateRequest request);
}

View File

@@ -73,7 +73,7 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati
PlanType = plan!.Type,
Seats = signup.AdditionalSeats,
MaxCollections = plan.PasswordManager.MaxCollections,
MaxStorageGb = 1,
MaxStorageGb = plan.PasswordManager.BaseStorageGb,
UsePolicies = plan.HasPolicies,
UseSso = plan.HasSso,
UseOrganizationDomains = plan.HasOrganizationDomains,

View File

@@ -0,0 +1,77 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.Billing.Organizations.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
public class OrganizationUpdateCommand(
IOrganizationService organizationService,
IOrganizationRepository organizationRepository,
IGlobalSettings globalSettings,
IOrganizationBillingService organizationBillingService
) : IOrganizationUpdateCommand
{
public async Task<Organization> UpdateAsync(OrganizationUpdateRequest request)
{
var organization = await organizationRepository.GetByIdAsync(request.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
if (globalSettings.SelfHosted)
{
return await UpdateSelfHostedAsync(organization, request);
}
return await UpdateCloudAsync(organization, request);
}
private async Task<Organization> UpdateCloudAsync(Organization organization, OrganizationUpdateRequest request)
{
// Store original values for comparison
var originalName = organization.Name;
var originalBillingEmail = organization.BillingEmail;
// Apply updates to organization
organization.UpdateDetails(request);
organization.BackfillPublicPrivateKeys(request);
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
// Update billing information in Stripe if required
await UpdateBillingAsync(organization, originalName, originalBillingEmail);
return organization;
}
/// <summary>
/// Self-host cannot update the organization details because they are set by the license file.
/// However, this command does offer a soft migration pathway for organizations without public and private keys.
/// If we remove this migration code in the future, this command and endpoint can become cloud only.
/// </summary>
private async Task<Organization> UpdateSelfHostedAsync(Organization organization, OrganizationUpdateRequest request)
{
organization.BackfillPublicPrivateKeys(request);
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
return organization;
}
private async Task UpdateBillingAsync(Organization organization, string originalName, string? originalBillingEmail)
{
// Update Stripe if name or billing email changed
var shouldUpdateBilling = originalName != organization.Name ||
originalBillingEmail != organization.BillingEmail;
if (!shouldUpdateBilling || string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
{
return;
}
await organizationBillingService.UpdateOrganizationNameAndEmail(organization);
}
}

View File

@@ -0,0 +1,43 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
public static class OrganizationUpdateExtensions
{
/// <summary>
/// Updates the organization name and/or billing email.
/// Any null property on the request object will be skipped.
/// </summary>
public static void UpdateDetails(this Organization organization, OrganizationUpdateRequest request)
{
// These values may or may not be sent by the client depending on the operation being performed.
// Skip any values not provided.
if (request.Name is not null)
{
organization.Name = request.Name;
}
if (request.BillingEmail is not null)
{
organization.BillingEmail = request.BillingEmail.ToLowerInvariant().Trim();
}
}
/// <summary>
/// Updates the organization public and private keys if provided and not already set.
/// This is legacy code for old organizations that were not created with a public/private keypair. It is a soft
/// migration that will silently migrate organizations when they change their details.
/// </summary>
public static void BackfillPublicPrivateKeys(this Organization organization, OrganizationUpdateRequest request)
{
if (!string.IsNullOrWhiteSpace(request.PublicKey) && string.IsNullOrWhiteSpace(organization.PublicKey))
{
organization.PublicKey = request.PublicKey;
}
if (!string.IsNullOrWhiteSpace(request.EncryptedPrivateKey) && string.IsNullOrWhiteSpace(organization.PrivateKey))
{
organization.PrivateKey = request.EncryptedPrivateKey;
}
}
}

View File

@@ -0,0 +1,33 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
/// <summary>
/// Request model for updating the name, billing email, and/or public-private keys for an organization (legacy migration code).
/// Any combination of these properties can be updated, so they are optional. If none are specified it will not update anything.
/// </summary>
public record OrganizationUpdateRequest
{
/// <summary>
/// The ID of the organization to update.
/// </summary>
public required Guid OrganizationId { get; init; }
/// <summary>
/// The new organization name to apply (optional, this is skipped if not provided).
/// </summary>
public string? Name { get; init; }
/// <summary>
/// The new billing email address to apply (optional, this is skipped if not provided).
/// </summary>
public string? BillingEmail { get; init; }
/// <summary>
/// The organization's public key to set (optional, only set if not already present on the organization).
/// </summary>
public string? PublicKey { get; init; }
/// <summary>
/// The organization's encrypted private key to set (optional, only set if not already present on the organization).
/// </summary>
public string? EncryptedPrivateKey { get; init; }
}

View File

@@ -9,6 +9,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies;
/// <summary>
/// Defines behavior and functionality for a given PolicyType.
/// </summary>
/// <remarks>
/// All methods defined in this interface are for the PolicyService#SavePolicy method. This needs to be supported until
/// we successfully refactor policy validators over to policy validation handlers
/// </remarks>
public interface IPolicyValidator
{
/// <summary>

View File

@@ -5,4 +5,18 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
public record SavePolicyModel(PolicyUpdate PolicyUpdate, IActingUser? PerformedBy, IPolicyMetadataModel Metadata)
{
public SavePolicyModel(PolicyUpdate PolicyUpdate)
: this(PolicyUpdate, null, new EmptyMetadataModel())
{
}
public SavePolicyModel(PolicyUpdate PolicyUpdate, IActingUser performedBy)
: this(PolicyUpdate, performedBy, new EmptyMetadataModel())
{
}
public SavePolicyModel(PolicyUpdate PolicyUpdate, IPolicyMetadataModel metadata)
: this(PolicyUpdate, null, metadata)
{
}
}

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,8 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyUpdateEvent, FreeFamiliesForEnterprisePolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, OrganizationDataOwnershipPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, UriMatchDefaultPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, BlockClaimedDomainAccountCreationPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, AutomaticUserConfirmationPolicyEventHandler>();
}
private static void AddPolicyRequirements(this IServiceCollection services)
@@ -64,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

@@ -2,6 +2,13 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
/// <summary>
/// Represents all policies required to be enabled before the given policy can be enabled.
/// </summary>
/// <remarks>
/// This interface is intended for policy event handlers that mandate the activation of other policies
/// as prerequisites for enabling the associated policy.
/// </remarks>
public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent
{
/// <summary>

View File

@@ -3,6 +3,12 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
/// <summary>
/// Represents all side effects that should be executed before a policy is upserted.
/// </summary>
/// <remarks>
/// This should be added to policy handlers that need to perform side effects before policy upserts.
/// </remarks>
public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent
{
/// <summary>

View File

@@ -2,6 +2,12 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
/// <summary>
/// Represents the policy to be upserted.
/// </summary>
/// <remarks>
/// This is used for the VNextSavePolicyCommand. All policy handlers should implement this interface.
/// </remarks>
public interface IPolicyUpdateEvent
{
/// <summary>

View File

@@ -3,12 +3,17 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
/// <summary>
/// Represents all validations that need to be run to enable or disable the given policy.
/// </summary>
/// <remarks>
/// This is used for the VNextSavePolicyCommand. This optional but should be implemented for all policies that have
/// certain requirements for the given organization.
/// </remarks>
public interface IPolicyValidationEvent : IPolicyUpdateEvent
{
/// <summary>
/// Performs side effects after a policy is validated but before it is saved.
/// For example, this can be used to remove non-compliant users from the organization.
/// Implementation is optional; by default, it will not perform any side effects.
/// Performs any validations required to enable or disable the policy.
/// </summary>
/// <param name="policyRequest">The policy save request containing the policy update and metadata</param>
/// <param name="currentPolicy">The current policy, if any</param>

View File

@@ -0,0 +1,131 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Repositories;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
/// <summary>
/// Represents an event handler for the Automatic User Confirmation policy.
///
/// This class validates that the following conditions are met:
/// <ul>
/// <li>The Single organization policy is enabled</li>
/// <li>All organization users are compliant with the Single organization policy</li>
/// <li>No provider users exist</li>
/// </ul>
///
/// This class also performs side effects when the policy is being enabled or disabled. They are:
/// <ul>
/// <li>Sets the UseAutomaticUserConfirmation organization feature to match the policy update</li>
/// </ul>
/// </summary>
public class AutomaticUserConfirmationPolicyEventHandler(
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository,
IPolicyRepository policyRepository,
IOrganizationRepository organizationRepository,
TimeProvider timeProvider)
: IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent
{
public PolicyType Type => PolicyType.AutomaticUserConfirmation;
public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) =>
await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy);
private const string _singleOrgPolicyNotEnabledErrorMessage =
"The Single organization policy must be enabled before enabling the Automatically confirm invited users policy.";
private const string _usersNotCompliantWithSingleOrgErrorMessage =
"All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations.";
private const string _providerUsersExistErrorMessage =
"The organization has users with the Provider user type. Please remove provider users before enabling the Automatically confirm invited users policy.";
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{
var isNotEnablingPolicy = policyUpdate is not { Enabled: true };
var policyAlreadyEnabled = currentPolicy is { Enabled: true };
if (isNotEnablingPolicy || policyAlreadyEnabled)
{
return string.Empty;
}
return await ValidateEnablingPolicyAsync(policyUpdate.OrganizationId);
}
public async Task<string> ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) =>
await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy);
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{
var organization = await organizationRepository.GetByIdAsync(policyUpdate.OrganizationId);
if (organization is not null)
{
organization.UseAutomaticUserConfirmation = policyUpdate.Enabled;
organization.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
await organizationRepository.UpsertAsync(organization);
}
}
private async Task<string> ValidateEnablingPolicyAsync(Guid organizationId)
{
var singleOrgValidationError = await ValidateSingleOrgPolicyComplianceAsync(organizationId);
if (!string.IsNullOrWhiteSpace(singleOrgValidationError))
{
return singleOrgValidationError;
}
var providerValidationError = await ValidateNoProviderUsersAsync(organizationId);
if (!string.IsNullOrWhiteSpace(providerValidationError))
{
return providerValidationError;
}
return string.Empty;
}
private async Task<string> ValidateSingleOrgPolicyComplianceAsync(Guid organizationId)
{
var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg);
if (singleOrgPolicy is not { Enabled: true })
{
return _singleOrgPolicyNotEnabledErrorMessage;
}
return await ValidateUserComplianceWithSingleOrgAsync(organizationId);
}
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId)
{
var organizationUsers = (await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId))
.Where(ou => ou.Status != OrganizationUserStatusType.Invited &&
ou.Status != OrganizationUserStatusType.Revoked &&
ou.UserId.HasValue)
.ToList();
if (organizationUsers.Count == 0)
{
return string.Empty;
}
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
organizationUsers.Select(ou => ou.UserId!.Value)))
.Any(uo => uo.OrganizationId != organizationId &&
uo.Status != OrganizationUserStatusType.Invited);
return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty;
}
private async Task<string> ValidateNoProviderUsersAsync(Guid organizationId)
{
var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId);
return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty;
}
}

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,18 @@ 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);
/// <summary>
/// Returns the OrganizationUserUserDetails if found.
/// </summary>
/// <param name="organizationId">The id of the organization</param>
/// <param name="userId">The id of the User to fetch</param>
/// <returns>OrganizationUserUserDetails of the specified user or null if not found</returns>
/// <remarks>
/// Similar to GetByOrganizationAsync, but returns the user details.
/// </remarks>
Task<OrganizationUserUserDetails?> GetDetailsByOrganizationIdUserIdAsync(Guid organizationId, Guid userId);
}

View File

@@ -1,4 +1,6 @@
namespace Bit.Core.Services;
using Bit.Core.Models.Slack;
namespace Bit.Core.Services;
/// <summary>Defines operations for interacting with Slack, including OAuth authentication, channel discovery,
/// and sending messages.</summary>
@@ -54,6 +56,6 @@ public interface ISlackService
/// <param name="token">A valid Slack OAuth access token.</param>
/// <param name="message">The message text to send.</param>
/// <param name="channelId">The channel ID to send the message to.</param>
/// <returns>A task that completes when the message has been sent.</returns>
Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
/// <returns>The response from Slack after sending the message.</returns>
Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
}

View File

@@ -1,10 +1,15 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Services;
@@ -13,8 +18,10 @@ public class EventIntegrationHandler<T>(
IEventIntegrationPublisher eventIntegrationPublisher,
IIntegrationFilterService integrationFilterService,
IIntegrationConfigurationDetailsCache configurationCache,
IUserRepository userRepository,
IFusionCache cache,
IGroupRepository groupRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
ILogger<EventIntegrationHandler<T>> logger)
: IEventMessageHandler
{
@@ -85,25 +92,52 @@ public class EventIntegrationHandler<T>(
}
}
private async Task<IntegrationTemplateContext> BuildContextAsync(EventMessage eventMessage, string template)
internal async Task<IntegrationTemplateContext> BuildContextAsync(EventMessage eventMessage, string template)
{
// Note: All of these cache calls use the default options, including TTL of 30 minutes
var context = new IntegrationTemplateContext(eventMessage);
if (IntegrationTemplateProcessor.TemplateRequiresGroup(template) && eventMessage.GroupId.HasValue)
{
context.Group = await cache.GetOrSetAsync<Group?>(
key: EventIntegrationsCacheConstants.BuildCacheKeyForGroup(eventMessage.GroupId.Value),
factory: async _ => await groupRepository.GetByIdAsync(eventMessage.GroupId.Value)
);
}
if (eventMessage.OrganizationId is not Guid organizationId)
{
return context;
}
if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue)
{
context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value);
context.User = await GetUserFromCacheAsync(organizationId, eventMessage.UserId.Value);
}
if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue)
{
context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value);
context.ActingUser = await GetUserFromCacheAsync(organizationId, eventMessage.ActingUserId.Value);
}
if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue)
if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template))
{
context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value);
context.Organization = await cache.GetOrSetAsync<Organization?>(
key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(organizationId),
factory: async _ => await organizationRepository.GetByIdAsync(organizationId)
);
}
return context;
}
private async Task<OrganizationUserUserDetails?> GetUserFromCacheAsync(Guid organizationId, Guid userId) =>
await cache.GetOrSetAsync<OrganizationUserUserDetails?>(
key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(organizationId, userId),
factory: async _ => await organizationUserRepository.GetDetailsByOrganizationIdUserIdAsync(
organizationId: organizationId,
userId: userId
)
);
}

View File

@@ -1,34 +0,0 @@
using Bit.Core.Models.Data;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Services;
public class EventRouteService(
[FromKeyedServices("broadcast")] IEventWriteService broadcastEventWriteService,
[FromKeyedServices("storage")] IEventWriteService storageEventWriteService,
IFeatureService _featureService) : IEventWriteService
{
public async Task CreateAsync(IEvent e)
{
if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations))
{
await broadcastEventWriteService.CreateAsync(e);
}
else
{
await storageEventWriteService.CreateAsync(e);
}
}
public async Task CreateManyAsync(IEnumerable<IEvent> e)
{
if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations))
{
await broadcastEventWriteService.CreateManyAsync(e);
}
else
{
await storageEventWriteService.CreateManyAsync(e);
}
}
}

View File

@@ -6,14 +6,43 @@ public class SlackIntegrationHandler(
ISlackService slackService)
: IntegrationHandlerBase<SlackIntegrationConfigurationDetails>
{
private static readonly HashSet<string> _retryableErrors = new(StringComparer.Ordinal)
{
"internal_error",
"message_limit_exceeded",
"rate_limited",
"ratelimited",
"service_unavailable"
};
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<SlackIntegrationConfigurationDetails> message)
{
await slackService.SendSlackMessageByChannelIdAsync(
var slackResponse = await slackService.SendSlackMessageByChannelIdAsync(
message.Configuration.Token,
message.RenderedTemplate,
message.Configuration.ChannelId
);
return new IntegrationHandlerResult(success: true, message: message);
if (slackResponse is null)
{
return new IntegrationHandlerResult(success: false, message: message)
{
FailureReason = "Slack response was null"
};
}
if (slackResponse.Ok)
{
return new IntegrationHandlerResult(success: true, message: message);
}
var result = new IntegrationHandlerResult(success: false, message: message) { FailureReason = slackResponse.Error };
if (_retryableErrors.Contains(slackResponse.Error))
{
result.Retryable = true;
}
return result;
}
}

View File

@@ -1,5 +1,6 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Web;
using Bit.Core.Models.Slack;
using Bit.Core.Settings;
@@ -71,7 +72,7 @@ public class SlackService(
public async Task<string> GetDmChannelByEmailAsync(string token, string email)
{
var userId = await GetUserIdByEmailAsync(token, email);
return await OpenDmChannel(token, userId);
return await OpenDmChannelAsync(token, userId);
}
public string GetRedirectUrl(string callbackUrl, string state)
@@ -97,21 +98,21 @@ public class SlackService(
}
var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access",
new FormUrlEncodedContent(new[]
{
new FormUrlEncodedContent([
new KeyValuePair<string, string>("client_id", _clientId),
new KeyValuePair<string, string>("client_secret", _clientSecret),
new KeyValuePair<string, string>("code", code),
new KeyValuePair<string, string>("redirect_uri", redirectUrl)
}));
]));
SlackOAuthResponse? result;
try
{
result = await tokenResponse.Content.ReadFromJsonAsync<SlackOAuthResponse>();
}
catch
catch (JsonException ex)
{
logger.LogError(ex, "Error parsing SlackOAuthResponse: invalid JSON");
result = null;
}
@@ -129,14 +130,25 @@ public class SlackService(
return result.AccessToken;
}
public async Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
public async Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message,
string channelId)
{
var payload = JsonContent.Create(new { channel = channelId, text = message });
var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/chat.postMessage");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = payload;
await _httpClient.SendAsync(request);
var response = await _httpClient.SendAsync(request);
try
{
return await response.Content.ReadFromJsonAsync<SlackSendMessageResponse>();
}
catch (JsonException ex)
{
logger.LogError(ex, "Error parsing Slack message response: invalid JSON");
return null;
}
}
private async Task<string> GetUserIdByEmailAsync(string token, string email)
@@ -144,7 +156,16 @@ public class SlackService(
var request = new HttpRequestMessage(HttpMethod.Get, $"{_slackApiBaseUrl}/users.lookupByEmail?email={email}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
SlackUserResponse? result;
try
{
result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
}
catch (JsonException ex)
{
logger.LogError(ex, "Error parsing SlackUserResponse: invalid JSON");
result = null;
}
if (result is null)
{
@@ -160,7 +181,7 @@ public class SlackService(
return result.User.Id;
}
private async Task<string> OpenDmChannel(string token, string userId)
private async Task<string> OpenDmChannelAsync(string token, string userId)
{
if (string.IsNullOrEmpty(userId))
return string.Empty;
@@ -170,7 +191,16 @@ public class SlackService(
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = payload;
var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
SlackDmResponse? result;
try
{
result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
}
catch (JsonException ex)
{
logger.LogError(ex, "Error parsing SlackDmResponse: invalid JSON");
result = null;
}
if (result is null)
{

View File

@@ -148,7 +148,7 @@ public class OrganizationService : IOrganizationService
}
var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, organization, storageAdjustmentGb,
plan.PasswordManager.StripeStoragePlanId);
plan.PasswordManager.StripeStoragePlanId, plan.PasswordManager.BaseStorageGb);
await ReplaceAndUpdateCacheAsync(organization);
return secret;
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Services;
using Bit.Core.Models.Slack;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.Services.NoopImplementations;
@@ -24,9 +25,10 @@ public class NoopSlackService : ISlackService
return string.Empty;
}
public Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
public Task<SlackSendMessageResponse?> SendSlackMessageByChannelIdAsync(string token, string message,
string channelId)
{
return Task.FromResult(0);
return Task.FromResult<SlackSendMessageResponse?>(null);
}
public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)

View File

@@ -1,6 +1,4 @@
#nullable enable
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
namespace Bit.Core.AdminConsole.Utilities;
@@ -26,7 +24,7 @@ public static partial class IntegrationTemplateProcessor
return match.Value; // Return unknown keys as keys - i.e. #Key#
}
return property?.GetValue(values)?.ToString() ?? "";
return property.GetValue(values)?.ToString() ?? string.Empty;
});
}
@@ -38,7 +36,8 @@ public static partial class IntegrationTemplateProcessor
}
return template.Contains("#UserName#", StringComparison.Ordinal)
|| template.Contains("#UserEmail#", StringComparison.Ordinal);
|| template.Contains("#UserEmail#", StringComparison.Ordinal)
|| template.Contains("#UserType#", StringComparison.Ordinal);
}
public static bool TemplateRequiresActingUser(string template)
@@ -49,7 +48,18 @@ public static partial class IntegrationTemplateProcessor
}
return template.Contains("#ActingUserName#", StringComparison.Ordinal)
|| template.Contains("#ActingUserEmail#", StringComparison.Ordinal);
|| template.Contains("#ActingUserEmail#", StringComparison.Ordinal)
|| template.Contains("#ActingUserType#", StringComparison.Ordinal);
}
public static bool TemplateRequiresGroup(string template)
{
if (string.IsNullOrEmpty(template))
{
return false;
}
return template.Contains("#GroupName#", StringComparison.Ordinal);
}
public static bool TemplateRequiresOrganization(string template)

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

@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Models.Api.Request.Accounts;
namespace Bit.Core.Auth.Attributes;
public class MarketingInitiativeValidationAttribute : ValidationAttribute
{
private static readonly string[] _acceptedValues = [MarketingInitiativeConstants.Premium];
public MarketingInitiativeValidationAttribute()
{
ErrorMessage = $"Marketing initiative type must be one of: {string.Join(", ", _acceptedValues)}";
}
public override bool IsValid(object? value)
{
if (value == null)
{
return true;
}
if (value is not string str)
{
return false;
}
return _acceptedValues.Contains(str);
}
}

View File

@@ -0,0 +1,10 @@
namespace Bit.Core.Auth.Models.Api.Request.Accounts;
public static class MarketingInitiativeConstants
{
/// <summary>
/// Indicates that the user began the registration process on a marketing page designed
/// to streamline users who intend to setup a premium subscription after registration.
/// </summary>
public const string Premium = "premium";
}

View File

@@ -1,5 +1,6 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Attributes;
using Bit.Core.Utilities;
namespace Bit.Core.Auth.Models.Api.Request.Accounts;
@@ -11,4 +12,6 @@ public class RegisterSendVerificationEmailRequestModel
[StringLength(256)]
public required string Email { get; set; }
public bool ReceiveMarketingEmails { get; set; }
[MarketingInitiativeValidation]
public string? FromMarketing { get; set; }
}

View File

@@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using Bit.Core.Entities;
using Bit.Core.Tokens;
@@ -26,7 +23,7 @@ public class OrgUserInviteTokenable : ExpiringTokenable
public string Identifier { get; set; } = TokenIdentifier;
public Guid OrgUserId { get; set; }
public string OrgUserEmail { get; set; }
public string? OrgUserEmail { get; set; }
[JsonConstructor]
public OrgUserInviteTokenable()

View File

@@ -15,11 +15,13 @@ public class RegisterVerifyEmail : BaseMailModel
// so we must land on a redirect connector which will redirect to the finish signup page.
// Note 3: The use of a fragment to indicate the redirect url is to prevent the query string from being logged by
// proxies and servers. It also helps reduce open redirect vulnerabilities.
public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true",
public string Url => string.Format("{0}/redirect-connector.html#finish-signup?token={1}&email={2}&fromEmail=true{3}",
WebVaultUrl,
Token,
Email);
Email,
!string.IsNullOrEmpty(FromMarketing) ? $"&fromMarketing={FromMarketing}" : string.Empty);
public string Token { get; set; }
public string Email { get; set; }
public string FromMarketing { get; set; }
}

View File

@@ -3,9 +3,11 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
@@ -24,7 +26,9 @@ public class SsoConfigService : ISsoConfigService
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IEventService _eventService;
private readonly IFeatureService _featureService;
private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
public SsoConfigService(
ISsoConfigRepository ssoConfigRepository,
@@ -32,14 +36,18 @@ public class SsoConfigService : ISsoConfigService
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IEventService eventService,
ISavePolicyCommand savePolicyCommand)
IFeatureService featureService,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand)
{
_ssoConfigRepository = ssoConfigRepository;
_policyRepository = policyRepository;
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_eventService = eventService;
_featureService = featureService;
_savePolicyCommand = savePolicyCommand;
_vNextSavePolicyCommand = vNextSavePolicyCommand;
}
public async Task SaveAsync(SsoConfig config, Organization organization)
@@ -67,13 +75,12 @@ public class SsoConfigService : ISsoConfigService
// Automatically enable account recovery, SSO required, and single org policies if trusted device encryption is selected
if (config.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption)
{
await _savePolicyCommand.SaveAsync(new()
var singleOrgPolicy = new PolicyUpdate
{
OrganizationId = config.OrganizationId,
Type = PolicyType.SingleOrg,
Enabled = true
});
};
var resetPasswordPolicy = new PolicyUpdate
{
@@ -82,14 +89,27 @@ public class SsoConfigService : ISsoConfigService
Enabled = true,
};
resetPasswordPolicy.SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = true });
await _savePolicyCommand.SaveAsync(resetPasswordPolicy);
await _savePolicyCommand.SaveAsync(new()
var requireSsoPolicy = new PolicyUpdate
{
OrganizationId = config.OrganizationId,
Type = PolicyType.RequireSso,
Enabled = true
});
};
if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor))
{
var performedBy = new SystemUser(EventSystemUser.Unknown);
await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(singleOrgPolicy, performedBy));
await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(resetPasswordPolicy, performedBy));
await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(requireSsoPolicy, performedBy));
}
else
{
await _savePolicyCommand.SaveAsync(singleOrgPolicy);
await _savePolicyCommand.SaveAsync(resetPasswordPolicy);
await _savePolicyCommand.SaveAsync(requireSsoPolicy);
}
}
await LogEventsAsync(config, oldConfig);

View File

@@ -1,4 +1,5 @@
using Bit.Core.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.Auth.UserFeatures.Registration;
@@ -14,6 +15,15 @@ public interface IRegisterUserCommand
/// <returns><see cref="IdentityResult"/></returns>
public Task<IdentityResult> RegisterUser(User user);
/// <summary>
/// Creates a new user, sends a welcome email, and raises the signup reference event.
/// This method is used by SSO auto-provisioned organization Users.
/// </summary>
/// <param name="user">The <see cref="User"/> to create</param>
/// <param name="organization">The <see cref="Organization"/> associated with the user</param>
/// <returns><see cref="IdentityResult"/></returns>
Task<IdentityResult> RegisterSSOAutoProvisionedUserAsync(User user, Organization organization);
/// <summary>
/// Creates a new user with a given master password hash, sends a welcome email (differs based on initiation path),
/// and raises the signup reference event. Optionally accepts an org invite token and org user id to associate

View File

@@ -3,5 +3,5 @@ namespace Bit.Core.Auth.UserFeatures.Registration;
public interface ISendVerificationEmailForRegistrationCommand
{
public Task<string?> Run(string email, string? name, bool receiveMarketingEmails);
public Task<string?> Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing);
}

View File

@@ -1,11 +1,10 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@@ -16,15 +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;
@@ -41,21 +45,28 @@ public class RegisterUserCommand : IRegisterUserCommand
private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator.";
public RegisterUserCommand(
IGlobalSettings globalSettings,
IOrganizationUserRepository organizationUserRepository,
IPolicyRepository policyRepository,
IDataProtectionProvider dataProtectionProvider,
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> registrationEmailVerificationTokenDataFactory,
IUserService userService,
IMailService mailService,
IValidateRedemptionTokenCommand validateRedemptionTokenCommand,
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> emergencyAccessInviteTokenDataFactory
)
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)
{
_logger = logger;
_globalSettings = globalSettings;
_organizationUserRepository = organizationUserRepository;
_organizationRepository = organizationRepository;
_policyRepository = policyRepository;
_organizationDomainRepository = organizationDomainRepository;
_featureService = featureService;
_organizationServiceDataProtector = dataProtectionProvider.CreateProtector(
"OrganizationServiceDataProtector");
@@ -69,11 +80,13 @@ public class RegisterUserCommand : IRegisterUserCommand
_emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory;
_providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
_featureService = featureService;
}
public async Task<IdentityResult> RegisterUser(User user)
{
await ValidateEmailDomainNotBlockedAsync(user.Email);
var result = await _userService.CreateUserAsync(user);
if (result == IdentityResult.Success)
{
@@ -83,11 +96,27 @@ public class RegisterUserCommand : IRegisterUserCommand
return result;
}
public async Task<IdentityResult> RegisterSSOAutoProvisionedUserAsync(User user, Organization organization)
{
var result = await _userService.CreateUserAsync(user);
if (result == IdentityResult.Success)
{
await SendWelcomeEmailAsync(user, organization);
}
return result;
}
public async Task<IdentityResult> RegisterUserViaOrganizationInviteToken(User user, string masterPasswordHash,
string orgInviteToken, Guid? orgUserId)
{
ValidateOrgInviteToken(orgInviteToken, orgUserId, user);
await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user);
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);
@@ -97,16 +126,17 @@ public class RegisterUserCommand : IRegisterUserCommand
}
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
var organization = await GetOrganizationUserOrganization(orgUserId ?? Guid.Empty, orgUser);
if (result == IdentityResult.Success)
{
var sentWelcomeEmail = false;
if (!string.IsNullOrEmpty(user.ReferenceData))
{
var referenceData = JsonConvert.DeserializeObject<Dictionary<string, object>>(user.ReferenceData);
var referenceData = JsonConvert.DeserializeObject<Dictionary<string, object>>(user.ReferenceData) ?? [];
if (referenceData.TryGetValue("initiationPath", out var value))
{
var initiationPath = value.ToString();
await SendAppropriateWelcomeEmailAsync(user, initiationPath);
var initiationPath = value.ToString() ?? string.Empty;
await SendAppropriateWelcomeEmailAsync(user, initiationPath, organization);
sentWelcomeEmail = true;
if (!string.IsNullOrEmpty(initiationPath))
{
@@ -117,14 +147,22 @@ public class RegisterUserCommand : IRegisterUserCommand
if (!sentWelcomeEmail)
{
await _mailService.SendWelcomeEmailAsync(user);
await SendWelcomeEmailAsync(user, organization);
}
}
return result;
}
private void ValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, User user)
/// <summary>
/// This method attempts to validate the org invite token if provided. If the token is invalid an exception is thrown.
/// If there is no exception it is assumed the token is valid or not provided and open registration is allowed.
/// </summary>
/// <param name="orgInviteToken">The organization invite token.</param>
/// <param name="orgUserId">The organization user ID.</param>
/// <param name="user">The user being registered.</param>
/// <exception cref="BadRequestException">If validation fails then an exception is thrown.</exception>
private void TryValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, User user)
{
var orgInviteTokenProvided = !string.IsNullOrWhiteSpace(orgInviteToken);
@@ -137,7 +175,6 @@ public class RegisterUserCommand : IRegisterUserCommand
}
// Token data is invalid
if (_globalSettings.DisableUserRegistration)
{
throw new BadRequestException(_disabledUserRegistrationExceptionMsg);
@@ -147,7 +184,6 @@ public class RegisterUserCommand : IRegisterUserCommand
}
// no token data or missing token data
// Throw if open registration is disabled and there isn't an org invite token or an org user id
// as you can't register without them.
if (_globalSettings.DisableUserRegistration)
@@ -171,12 +207,20 @@ public class RegisterUserCommand : IRegisterUserCommand
// If both orgInviteToken && orgUserId are missing, then proceed with open registration
}
/// <summary>
/// Validates the org invite token using the new tokenable logic first, then falls back to the old token validation logic for backwards compatibility.
/// Will set the out parameter organizationWelcomeEmailDetails if the new token is valid. If the token is invalid then no welcome email needs to be sent
/// so the out parameter is set to null.
/// </summary>
/// <param name="orgInviteToken">Invite token</param>
/// <param name="orgUserId">Inviting Organization UserId</param>
/// <param name="userEmail">User email</param>
/// <returns>true if the token is valid false otherwise</returns>
private bool IsOrgInviteTokenValid(string orgInviteToken, Guid orgUserId, string userEmail)
{
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
var newOrgInviteTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
_orgUserInviteTokenDataFactory, orgInviteToken, orgUserId, userEmail);
return newOrgInviteTokenValid || CoreHelpers.UserInviteTokenIsValid(
_organizationServiceDataProtector, orgInviteToken, userEmail, orgUserId, _globalSettings);
}
@@ -187,11 +231,12 @@ public class RegisterUserCommand : IRegisterUserCommand
/// </summary>
/// <param name="orgUserId">The optional org user id</param>
/// <param name="user">The newly created user object which could be modified</param>
private async Task SetUserEmail2FaIfOrgPolicyEnabledAsync(Guid? orgUserId, User user)
/// <returns>The organization user if one exists for the provided org user id, null otherwise</returns>
private async Task<OrganizationUser?> SetUserEmail2FaIfOrgPolicyEnabledAsync(Guid? orgUserId, User user)
{
if (!orgUserId.HasValue)
{
return;
return null;
}
var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value);
@@ -213,10 +258,11 @@ public class RegisterUserCommand : IRegisterUserCommand
_userService.SetTwoFactorProvider(user, TwoFactorProviderType.Email);
}
}
return orgUser;
}
private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath)
private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath, Organization? organization)
{
var isFromMarketingWebsite = initiationPath.Contains("Secrets Manager trial");
@@ -226,15 +272,15 @@ public class RegisterUserCommand : IRegisterUserCommand
}
else
{
await _mailService.SendWelcomeEmailAsync(user);
await SendWelcomeEmailAsync(user, organization);
}
}
public async Task<IdentityResult> RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash,
string emailVerificationToken)
{
ValidateOpenRegistrationAllowed();
await ValidateEmailDomainNotBlockedAsync(user.Email);
var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email);
@@ -245,7 +291,7 @@ public class RegisterUserCommand : IRegisterUserCommand
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success)
{
await _mailService.SendWelcomeEmailAsync(user);
await SendWelcomeEmailAsync(user);
}
return result;
@@ -255,6 +301,7 @@ public class RegisterUserCommand : IRegisterUserCommand
string orgSponsoredFreeFamilyPlanInviteToken)
{
ValidateOpenRegistrationAllowed();
await ValidateEmailDomainNotBlockedAsync(user.Email);
await ValidateOrgSponsoredFreeFamilyPlanInviteToken(orgSponsoredFreeFamilyPlanInviteToken, user.Email);
user.EmailVerified = true;
@@ -263,7 +310,7 @@ public class RegisterUserCommand : IRegisterUserCommand
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success)
{
await _mailService.SendWelcomeEmailAsync(user);
await SendWelcomeEmailAsync(user);
}
return result;
@@ -275,6 +322,7 @@ public class RegisterUserCommand : IRegisterUserCommand
string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId)
{
ValidateOpenRegistrationAllowed();
await ValidateEmailDomainNotBlockedAsync(user.Email);
ValidateAcceptEmergencyAccessInviteToken(acceptEmergencyAccessInviteToken, acceptEmergencyAccessId, user.Email);
user.EmailVerified = true;
@@ -283,7 +331,7 @@ public class RegisterUserCommand : IRegisterUserCommand
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success)
{
await _mailService.SendWelcomeEmailAsync(user);
await SendWelcomeEmailAsync(user);
}
return result;
@@ -293,6 +341,7 @@ public class RegisterUserCommand : IRegisterUserCommand
string providerInviteToken, Guid providerUserId)
{
ValidateOpenRegistrationAllowed();
await ValidateEmailDomainNotBlockedAsync(user.Email);
ValidateProviderInviteToken(providerInviteToken, providerUserId, user.Email);
user.EmailVerified = true;
@@ -301,7 +350,7 @@ public class RegisterUserCommand : IRegisterUserCommand
var result = await _userService.CreateUserAsync(user, masterPasswordHash);
if (result == IdentityResult.Success)
{
await _mailService.SendWelcomeEmailAsync(user);
await SendWelcomeEmailAsync(user);
}
return result;
@@ -357,4 +406,81 @@ 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.
/// </summary>
/// <param name="user">Target user for the email</param>
/// <param name="organization">this value is nullable</param>
/// <returns></returns>
private async Task SendWelcomeEmailAsync(User user, Organization? organization = null)
{
// Check if feature is enabled
// TODO: Remove Feature flag: PM-28221
if (!_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates))
{
await _mailService.SendWelcomeEmailAsync(user);
return;
}
// Most emails are probably for non organization users so we default to that experience
if (organization == null)
{
await _mailService.SendIndividualUserWelcomeEmailAsync(user);
}
// We need to make sure that the organization email has the correct data to display otherwise we just send the standard welcome email
else if (!string.IsNullOrEmpty(organization.DisplayName()))
{
// If the organization is Free or Families plan, send families welcome email
if (organization.PlanType is PlanType.FamiliesAnnually
or PlanType.FamiliesAnnually2019
or PlanType.Free)
{
await _mailService.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.DisplayName());
}
else
{
await _mailService.SendOrganizationUserWelcomeEmailAsync(user, organization.DisplayName());
}
}
// If the organization data isn't present send the standard welcome email
else
{
await _mailService.SendIndividualUserWelcomeEmailAsync(user);
}
}
private async Task<Organization?> GetOrganizationUserOrganization(Guid orgUserId, OrganizationUser? orgUser = null)
{
var organizationUser = orgUser ?? await _organizationUserRepository.GetByIdAsync(orgUserId);
if (organizationUser == null)
{
return null;
}
return await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId);
}
}

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,29 +17,34 @@ 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;
}
public async Task<string?> Run(string email, string? name, bool receiveMarketingEmails)
public async Task<string?> Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing)
{
if (_globalSettings.DisableUserRegistration)
{
@@ -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;
@@ -71,7 +92,7 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai
// If the user doesn't exist, create a new EmailVerificationTokenable and send the user
// an email with a link to verify their email address
var token = GenerateToken(email, name, receiveMarketingEmails);
await _mailService.SendRegistrationVerificationEmailAsync(email, token);
await _mailService.SendRegistrationVerificationEmailAsync(email, token, fromMarketing);
}
// User exists but we will return a 200 regardless of whether the email was sent or not; so return null

View File

@@ -22,6 +22,8 @@ public static class StripeConstants
{
public const string LegacyMSPDiscount = "msp-discount-35";
public const string SecretsManagerStandalone = "sm-standalone";
public const string Milestone2SubscriptionDiscount = "milestone-2c";
public const string Milestone3SubscriptionDiscount = "milestone-3";
public static class MSPDiscounts
{
@@ -63,6 +65,7 @@ public static class StripeConstants
public const string Region = "region";
public const string RetiredBraintreeCustomerId = "btCustomerId_old";
public const string UserId = "userId";
public const string StorageReconciled2025 = "storage_reconciled_2025";
}
public static class PaymentBehavior

View File

@@ -18,8 +18,8 @@ public enum PlanType : byte
EnterpriseAnnually2019 = 5,
[Display(Name = "Custom")]
Custom = 6,
[Display(Name = "Families")]
FamiliesAnnually = 7,
[Display(Name = "Families 2025")]
FamiliesAnnually2025 = 7,
[Display(Name = "Teams (Monthly) 2020")]
TeamsMonthly2020 = 8,
[Display(Name = "Teams (Annually) 2020")]
@@ -48,4 +48,6 @@ public enum PlanType : byte
EnterpriseAnnually = 20,
[Display(Name = "Teams Starter")]
TeamsStarter = 21,
[Display(Name = "Families")]
FamiliesAnnually = 22,
}

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