mirror of
https://github.com/bitwarden/server
synced 2026-01-14 06:23:46 +00:00
Merge branch 'main' of github.com:bitwarden/server into arch/seeder-api
This commit is contained in:
@@ -496,6 +496,7 @@ public class OrganizationsController : Controller
|
||||
organization.UseOrganizationDomains = model.UseOrganizationDomains;
|
||||
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
|
||||
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
|
||||
organization.UseDisableSmAdsForUsers = model.UseDisableSmAdsForUsers;
|
||||
organization.UsePhishingBlocker = model.UsePhishingBlocker;
|
||||
|
||||
//secrets
|
||||
|
||||
@@ -107,6 +107,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
|
||||
UseOrganizationDomains = org.UseOrganizationDomains;
|
||||
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
|
||||
UseDisableSmAdsForUsers = org.UseDisableSmAdsForUsers;
|
||||
UsePhishingBlocker = org.UsePhishingBlocker;
|
||||
|
||||
_plans = plans;
|
||||
@@ -196,6 +197,8 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
public int? MaxAutoscaleSmServiceAccounts { get; set; }
|
||||
[Display(Name = "Use Organization Domains")]
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
[Display(Name = "Disable SM Ads For Users")]
|
||||
public new bool UseDisableSmAdsForUsers { get; set; }
|
||||
|
||||
[Display(Name = "Automatic User Confirmation")]
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
@@ -330,6 +333,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
||||
existingOrganization.SmServiceAccounts = SmServiceAccounts;
|
||||
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
|
||||
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
|
||||
existingOrganization.UseDisableSmAdsForUsers = UseDisableSmAdsForUsers;
|
||||
existingOrganization.UsePhishingBlocker = UsePhishingBlocker;
|
||||
return existingOrganization;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ public class OrganizationViewModel
|
||||
public bool UseSecretsManager => Organization.UseSecretsManager;
|
||||
public bool UseRiskInsights => Organization.UseRiskInsights;
|
||||
public bool UsePhishingBlocker => Organization.UsePhishingBlocker;
|
||||
public bool UseDisableSmAdsForUsers => Organization.UseDisableSmAdsForUsers;
|
||||
public IEnumerable<OrganizationUserUserDetails> OwnersDetails { get; set; }
|
||||
public IEnumerable<OrganizationUserUserDetails> AdminsDetails { get; set; }
|
||||
}
|
||||
|
||||
@@ -185,6 +185,13 @@
|
||||
<input type="checkbox" class="form-check-input" asp-for="UseSecretsManager" disabled='@(canEditPlan ? null : "disabled")'>
|
||||
<label class="form-check-label" asp-for="UseSecretsManager"></label>
|
||||
</div>
|
||||
@if (FeatureService.IsEnabled(FeatureFlagKeys.SM1719_RemoveSecretsManagerAds))
|
||||
{
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" asp-for="UseDisableSmAdsForUsers" disabled='@(canEditPlan ? null : "disabled")'>
|
||||
<label class="form-check-label" asp-for="UseDisableSmAdsForUsers"></label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<h3>Access Intelligence</h3>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###############################################
|
||||
# Node.js build stage #
|
||||
###############################################
|
||||
FROM node:20-alpine3.21 AS node-build
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine3.21 AS node-build
|
||||
|
||||
WORKDIR /app
|
||||
COPY src/Admin/package*.json ./
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using Bit.Core.Context;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Authorization.Requirements;
|
||||
|
||||
/// <summary>
|
||||
/// Requires that the user is a member of the organization.
|
||||
/// </summary>
|
||||
public class MemberRequirement : IOrganizationRequirement
|
||||
{
|
||||
public Task<bool> AuthorizeAsync(
|
||||
CurrentContextOrganization? organizationClaims,
|
||||
Func<Task<bool>> isProviderUserForOrg)
|
||||
=> Task.FromResult(organizationClaims is not null);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@@ -81,6 +82,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
|
||||
private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand;
|
||||
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
|
||||
private readonly ISelfRevokeOrganizationUserCommand _selfRevokeOrganizationUserCommand;
|
||||
|
||||
public OrganizationUsersController(IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@@ -112,7 +114,8 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand,
|
||||
IAdminRecoverAccountCommand adminRecoverAccountCommand,
|
||||
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand,
|
||||
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext)
|
||||
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext,
|
||||
ISelfRevokeOrganizationUserCommand selfRevokeOrganizationUserCommand)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@@ -145,6 +148,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
_initPendingOrganizationCommand = initPendingOrganizationCommand;
|
||||
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
|
||||
_adminRecoverAccountCommand = adminRecoverAccountCommand;
|
||||
_selfRevokeOrganizationUserCommand = selfRevokeOrganizationUserCommand;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@@ -635,6 +639,20 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
||||
await RestoreOrRevokeUserAsync(orgId, id, _revokeOrganizationUserCommand.RevokeUserAsync);
|
||||
}
|
||||
|
||||
[HttpPut("revoke-self")]
|
||||
[Authorize<MemberRequirement>]
|
||||
public async Task<IResult> RevokeSelfAsync(Guid orgId)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User);
|
||||
if (!userId.HasValue)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var result = await _selfRevokeOrganizationUserCommand.SelfRevokeUserAsync(orgId, userId.Value);
|
||||
return Handle(result);
|
||||
}
|
||||
|
||||
[HttpPatch("{id}/revoke")]
|
||||
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
|
||||
[Authorize<ManageUsersRequirement>]
|
||||
|
||||
@@ -7,7 +7,6 @@ using Bit.Api.AdminConsole.Models.Request;
|
||||
using Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
@@ -212,7 +211,6 @@ public class PoliciesController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("{type}/vnext")]
|
||||
[RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)]
|
||||
[Authorize<ManagePoliciesRequirement>]
|
||||
public async Task<PolicyResponseModel> PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model)
|
||||
{
|
||||
|
||||
@@ -57,8 +57,7 @@ public class ProviderClientsController(
|
||||
Owner = user,
|
||||
BillingEmail = provider.BillingEmail,
|
||||
OwnerKey = requestBody.Key,
|
||||
PublicKey = requestBody.KeyPair.PublicKey,
|
||||
PrivateKey = requestBody.KeyPair.EncryptedPrivateKey,
|
||||
Keys = requestBody.KeyPair.ToPublicKeyEncryptionKeyPairData(),
|
||||
CollectionName = requestBody.CollectionName,
|
||||
IsFromProvider = true
|
||||
};
|
||||
|
||||
@@ -113,11 +113,10 @@ public class OrganizationCreateRequestModel : IValidatableObject
|
||||
BillingAddressCountry = BillingAddressCountry,
|
||||
},
|
||||
InitiationPath = InitiationPath,
|
||||
SkipTrial = SkipTrial
|
||||
SkipTrial = SkipTrial,
|
||||
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
|
||||
};
|
||||
|
||||
Keys?.ToOrganizationSignup(orgSignup);
|
||||
|
||||
return orgSignup;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
|
||||
@@ -14,48 +13,10 @@ public class OrganizationKeysRequestModel
|
||||
[Required]
|
||||
public string EncryptedPrivateKey { get; set; }
|
||||
|
||||
public OrganizationSignup ToOrganizationSignup(OrganizationSignup existingSignup)
|
||||
public PublicKeyEncryptionKeyPairData ToPublicKeyEncryptionKeyPairData()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(existingSignup.PublicKey))
|
||||
{
|
||||
existingSignup.PublicKey = PublicKey;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existingSignup.PrivateKey))
|
||||
{
|
||||
existingSignup.PrivateKey = EncryptedPrivateKey;
|
||||
}
|
||||
|
||||
return existingSignup;
|
||||
}
|
||||
|
||||
public OrganizationUpgrade ToOrganizationUpgrade(OrganizationUpgrade existingUpgrade)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(existingUpgrade.PublicKey))
|
||||
{
|
||||
existingUpgrade.PublicKey = PublicKey;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existingUpgrade.PrivateKey))
|
||||
{
|
||||
existingUpgrade.PrivateKey = EncryptedPrivateKey;
|
||||
}
|
||||
|
||||
return existingUpgrade;
|
||||
}
|
||||
|
||||
public Organization ToOrganization(Organization existingOrg)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(existingOrg.PublicKey))
|
||||
{
|
||||
existingOrg.PublicKey = PublicKey;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(existingOrg.PrivateKey))
|
||||
{
|
||||
existingOrg.PrivateKey = EncryptedPrivateKey;
|
||||
}
|
||||
|
||||
return existingOrg;
|
||||
return new PublicKeyEncryptionKeyPairData(
|
||||
wrappedPrivateKey: EncryptedPrivateKey,
|
||||
publicKey: PublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,10 +110,9 @@ public class OrganizationNoPaymentCreateRequest
|
||||
BillingAddressCountry = BillingAddressCountry,
|
||||
},
|
||||
InitiationPath = InitiationPath,
|
||||
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
|
||||
};
|
||||
|
||||
Keys?.ToOrganizationSignup(orgSignup);
|
||||
|
||||
return orgSignup;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ public class OrganizationUpdateRequestModel
|
||||
OrganizationId = organizationId,
|
||||
Name = Name,
|
||||
BillingEmail = BillingEmail,
|
||||
PublicKey = Keys?.PublicKey,
|
||||
EncryptedPrivateKey = Keys?.EncryptedPrivateKey
|
||||
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,11 +43,10 @@ public class OrganizationUpgradeRequestModel
|
||||
{
|
||||
BillingAddressCountry = BillingAddressCountry,
|
||||
BillingAddressPostalCode = BillingAddressPostalCode
|
||||
}
|
||||
},
|
||||
Keys = Keys?.ToPublicKeyEncryptionKeyPairData()
|
||||
};
|
||||
|
||||
Keys?.ToOrganizationUpgrade(orgUpgrade);
|
||||
|
||||
return orgUpgrade;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
|
||||
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
|
||||
UseSecretsManager = organizationDetails.UseSecretsManager;
|
||||
UsePhishingBlocker = organizationDetails.UsePhishingBlocker;
|
||||
UseDisableSMAdsForUsers = organizationDetails.UseDisableSMAdsForUsers;
|
||||
UsePasswordManager = organizationDetails.UsePasswordManager;
|
||||
SelfHost = organizationDetails.SelfHost;
|
||||
Seats = organizationDetails.Seats;
|
||||
@@ -100,6 +101,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
public bool UseDisableSMAdsForUsers { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
public bool SelfHost { get; set; }
|
||||
public int? Seats { get; set; }
|
||||
|
||||
@@ -74,6 +74,7 @@ public class OrganizationResponseModel : ResponseModel
|
||||
UseOrganizationDomains = organization.UseOrganizationDomains;
|
||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
||||
UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers;
|
||||
UsePhishingBlocker = organization.UsePhishingBlocker;
|
||||
}
|
||||
|
||||
@@ -124,6 +125,7 @@ public class OrganizationResponseModel : ResponseModel
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
public bool UseDisableSmAdsForUsers { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Models.Data;
|
||||
@@ -13,6 +14,12 @@ namespace Bit.Api.AdminConsole.Public.Models.Response;
|
||||
/// </summary>
|
||||
public class GroupResponseModel : GroupBaseModel, IResponseModel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public GroupResponseModel()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public GroupResponseModel(Group group, IEnumerable<CollectionAccessSelection> collections)
|
||||
{
|
||||
if (group == null)
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
|
||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.31.0" />
|
||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="5.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace Bit.Api.Auth.Controllers;
|
||||
|
||||
[Route("webauthn")]
|
||||
[Authorize(Policies.Web)]
|
||||
public class WebAuthnController : Controller
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
@@ -62,6 +61,7 @@ public class WebAuthnController : Controller
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[Authorize(Policies.Web)]
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<WebAuthnCredentialResponseModel>> Get()
|
||||
{
|
||||
@@ -71,6 +71,7 @@ public class WebAuthnController : Controller
|
||||
return new ListResponseModel<WebAuthnCredentialResponseModel>(credentials.Select(c => new WebAuthnCredentialResponseModel(c)));
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("attestation-options")]
|
||||
public async Task<WebAuthnCredentialCreateOptionsResponseModel> AttestationOptions([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
@@ -88,6 +89,7 @@ public class WebAuthnController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Web)]
|
||||
[HttpPost("assertion-options")]
|
||||
public async Task<WebAuthnLoginAssertionOptionsResponseModel> AssertionOptions([FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
@@ -104,6 +106,7 @@ public class WebAuthnController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("")]
|
||||
public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel model)
|
||||
{
|
||||
@@ -149,6 +152,7 @@ public class WebAuthnController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPut()]
|
||||
public async Task UpdateCredential([FromBody] WebAuthnLoginCredentialUpdateRequestModel model)
|
||||
{
|
||||
@@ -172,6 +176,7 @@ public class WebAuthnController : Controller
|
||||
await _credentialRepository.UpdateAsync(credential);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Web)]
|
||||
[HttpPost("{id}/delete")]
|
||||
public async Task Delete(Guid id, [FromBody] SecretVerificationRequestModel model)
|
||||
{
|
||||
|
||||
@@ -273,7 +273,7 @@ public class TwoFactorWebAuthnDeleteRequestModel : SecretVerificationRequestMode
|
||||
yield return validationResult;
|
||||
}
|
||||
|
||||
if (!Id.HasValue || Id < 0 || Id > 5)
|
||||
if (!Id.HasValue)
|
||||
{
|
||||
yield return new ValidationResult("Invalid Key Id", new string[] { nameof(Id) });
|
||||
}
|
||||
|
||||
@@ -3,13 +3,10 @@ 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;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
@@ -22,59 +19,9 @@ namespace Bit.Api.Billing.Controllers;
|
||||
[Authorize("Application")]
|
||||
public class AccountsController(
|
||||
IUserService userService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IUserAccountKeysQuery userAccountKeysQuery,
|
||||
IFeatureService featureService,
|
||||
ILicensingService licensingService) : Controller
|
||||
{
|
||||
// TODO: Remove when pm-24996-implement-upgrade-from-free-dialog is removed
|
||||
[HttpPost("premium")]
|
||||
public async Task<PaymentResponseModel> PostPremiumAsync(
|
||||
PremiumRequestModel model,
|
||||
[FromServices] GlobalSettings globalSettings)
|
||||
{
|
||||
var user = await userService.GetUserByPrincipalAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var valid = model.Validate(globalSettings);
|
||||
UserLicense? license = null;
|
||||
if (valid && globalSettings.SelfHosted)
|
||||
{
|
||||
license = await ApiHelpers.ReadJsonFileFromBody<UserLicense>(HttpContext, model.License);
|
||||
}
|
||||
|
||||
if (!valid && !globalSettings.SelfHosted && string.IsNullOrWhiteSpace(model.Country))
|
||||
{
|
||||
throw new BadRequestException("Country is required.");
|
||||
}
|
||||
|
||||
if (!valid || (globalSettings.SelfHosted && license == null))
|
||||
{
|
||||
throw new BadRequestException("Invalid license.");
|
||||
}
|
||||
|
||||
var result = await userService.SignUpPremiumAsync(user, model.PaymentToken,
|
||||
model.PaymentMethodType!.Value, model.AdditionalStorageGb.GetValueOrDefault(0), license,
|
||||
new TaxInfo { BillingAddressCountry = model.Country, BillingAddressPostalCode = model.PostalCode });
|
||||
|
||||
var userTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
|
||||
var userHasPremiumFromOrganization = await userService.HasPremiumFromOrganization(user);
|
||||
var organizationIdsClaimingActiveUser = await GetOrganizationIdsClaimingUserAsync(user.Id);
|
||||
var accountKeys = await userAccountKeysQuery.Run(user);
|
||||
|
||||
var profile = new ProfileResponseModel(user, accountKeys, null, null, null, userTwoFactorEnabled,
|
||||
userHasPremiumFromOrganization, organizationIdsClaimingActiveUser);
|
||||
return new PaymentResponseModel
|
||||
{
|
||||
UserProfile = profile,
|
||||
PaymentIntentClientSecret = result.Item2,
|
||||
Success = result.Item1
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work.
|
||||
[HttpGet("subscription")]
|
||||
public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
|
||||
|
||||
91
src/Api/Billing/Controllers/LicensesController.cs
Normal file
91
src/Api/Billing/Controllers/LicensesController.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Organizations.Queries;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Api.OrganizationLicenses;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Route("licenses")]
|
||||
[Authorize("Licensing")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public class LicensesController : Controller
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery;
|
||||
private readonly IValidateBillingSyncKeyCommand _validateBillingSyncKeyCommand;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public LicensesController(
|
||||
IUserRepository userRepository,
|
||||
IUserService userService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery,
|
||||
IValidateBillingSyncKeyCommand validateBillingSyncKeyCommand,
|
||||
ICurrentContext currentContext)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_userService = userService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery;
|
||||
_validateBillingSyncKeyCommand = validateBillingSyncKeyCommand;
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
[HttpGet("user/{id}")]
|
||||
public async Task<UserLicense> GetUser(string id, [FromQuery] string key)
|
||||
{
|
||||
var user = await _userRepository.GetByIdAsync(new Guid(id));
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
else if (!user.LicenseKey.Equals(key))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid license key.");
|
||||
}
|
||||
|
||||
var license = await _userService.GenerateLicenseAsync(user, null);
|
||||
return license;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used by self-hosted installations to get an updated license file
|
||||
/// </summary>
|
||||
[HttpGet("organization/{id}")]
|
||||
public async Task<OrganizationLicense> OrganizationSync(string id, [FromBody] SelfHostedOrganizationLicenseRequestModel model)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(new Guid(id));
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException("Organization not found.");
|
||||
}
|
||||
|
||||
if (!organization.LicenseKey.Equals(model.LicenseKey))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid license key.");
|
||||
}
|
||||
|
||||
if (!await _validateBillingSyncKeyCommand.ValidateBillingSyncKeyAsync(organization, model.BillingSyncKey))
|
||||
{
|
||||
throw new BadRequestException("Invalid Billing Sync Key");
|
||||
}
|
||||
|
||||
var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value);
|
||||
return license;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using Bit.Api.Billing.Attributes;
|
||||
using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Api.Billing.Models.Requests.Premium;
|
||||
using Bit.Api.Billing.Models.Requests.Storage;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Licenses.Queries;
|
||||
using Bit.Core.Billing.Payment.Commands;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
@@ -21,7 +23,9 @@ public class AccountBillingVNextController(
|
||||
ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand,
|
||||
IGetCreditQuery getCreditQuery,
|
||||
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||
IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
|
||||
IGetUserLicenseQuery getUserLicenseQuery,
|
||||
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
|
||||
IUpdatePremiumStorageCommand updatePremiumStorageCommand) : BaseBillingController
|
||||
{
|
||||
[HttpGet("credit")]
|
||||
[InjectUser]
|
||||
@@ -66,7 +70,6 @@ public class AccountBillingVNextController(
|
||||
}
|
||||
|
||||
[HttpPost("subscription")]
|
||||
[RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> CreateSubscriptionAsync(
|
||||
[BindNever] User user,
|
||||
@@ -77,4 +80,24 @@ public class AccountBillingVNextController(
|
||||
user, paymentMethod, billingAddress, additionalStorageGb);
|
||||
return Handle(result);
|
||||
}
|
||||
|
||||
[HttpGet("license")]
|
||||
[InjectUser]
|
||||
public async Task<IResult> GetLicenseAsync(
|
||||
[BindNever] User user)
|
||||
{
|
||||
var response = await getUserLicenseQuery.Run(user);
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
[HttpPut("storage")]
|
||||
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> UpdateStorageAsync(
|
||||
[BindNever] User user,
|
||||
[FromBody] StorageUpdateRequest request)
|
||||
{
|
||||
var result = await updatePremiumStorageCommand.Run(user, request.AdditionalStorageGb);
|
||||
return Handle(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Bit.Api.Billing.Attributes;
|
||||
using Bit.Api.Billing.Models.Requests.Premium;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Entities;
|
||||
@@ -20,7 +19,6 @@ public class SelfHostedAccountBillingVNextController(
|
||||
ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController
|
||||
{
|
||||
[HttpPost("license")]
|
||||
[RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)]
|
||||
[InjectUser]
|
||||
public async Task<IResult> UploadLicenseAsync(
|
||||
[BindNever] User user,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests;
|
||||
|
||||
@@ -12,4 +13,11 @@ public class KeyPairRequestBody
|
||||
public string PublicKey { get; set; }
|
||||
[Required(ErrorMessage = "'encryptedPrivateKey' must be provided")]
|
||||
public string EncryptedPrivateKey { get; set; }
|
||||
|
||||
public PublicKeyEncryptionKeyPairData ToPublicKeyEncryptionKeyPairData()
|
||||
{
|
||||
return new PublicKeyEncryptionKeyPairData(
|
||||
wrappedPrivateKey: EncryptedPrivateKey,
|
||||
publicKey: PublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for updating storage allocation on a user's premium subscription.
|
||||
/// Allows for both increasing and decreasing storage in an idempotent manner.
|
||||
/// </summary>
|
||||
public class StorageUpdateRequest : IValidatableObject
|
||||
{
|
||||
/// <summary>
|
||||
/// The additional storage in GB beyond the base storage.
|
||||
/// Must be between 0 and the maximum allowed (minus base storage).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Range(0, 99)]
|
||||
public short AdditionalStorageGb { get; set; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (AdditionalStorageGb < 0)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Additional storage cannot be negative.",
|
||||
new[] { nameof(AdditionalStorageGb) });
|
||||
}
|
||||
|
||||
if (AdditionalStorageGb > 99)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Maximum additional storage is 99 GB.",
|
||||
new[] { nameof(AdditionalStorageGb) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Api.Utilities.DiagnosticTools;
|
||||
@@ -17,7 +18,7 @@ using Bit.Core.Vault.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("events")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,12 +1,12 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
|
||||
using Bit.Api.Dirt.Models.Request;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,12 +1,12 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
using Bit.Api.Dirt.Models.Request;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("organizations/{organizationId:guid}/integrations")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,16 +1,16 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Enums;
|
||||
using Bit.Core.Dirt.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Dirt.Repositories;
|
||||
using Bit.Core.Dirt.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("organizations")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,18 +1,18 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Enums;
|
||||
using Bit.Core.Dirt.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Dirt.Repositories;
|
||||
using Bit.Core.Dirt.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Bot.Builder;
|
||||
using Microsoft.Bot.Builder.Integration.AspNet.Core;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("organizations")]
|
||||
[Authorize("Application")]
|
||||
@@ -1,8 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
namespace Bit.Api.Dirt.Models.Request;
|
||||
|
||||
public class OrganizationIntegrationConfigurationRequestModel
|
||||
{
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Enums;
|
||||
using Bit.Core.Dirt.Models.Data.EventIntegrations;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
namespace Bit.Api.Dirt.Models.Request;
|
||||
|
||||
public class OrganizationIntegrationRequestModel : IValidatableObject
|
||||
{
|
||||
@@ -2,7 +2,7 @@
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Api.Models.Response;
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
public class EventResponseModel : ResponseModel
|
||||
{
|
||||
@@ -1,8 +1,8 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
|
||||
{
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Dirt.Entities;
|
||||
using Bit.Core.Dirt.Enums;
|
||||
using Bit.Core.Dirt.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Models.Api;
|
||||
|
||||
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
public class OrganizationIntegrationResponseModel : ResponseModel
|
||||
{
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
using System.Net;
|
||||
using Bit.Api.Models.Public.Request;
|
||||
using System.Net;
|
||||
using Bit.Api.Dirt.Public.Models;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Api.Utilities.DiagnosticTools;
|
||||
using Bit.Core.Context;
|
||||
@@ -12,7 +11,7 @@ using Bit.Core.Vault.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Public.Controllers;
|
||||
namespace Bit.Api.Dirt.Public.Controllers;
|
||||
|
||||
[Route("public/events")]
|
||||
[Authorize("Organization")]
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
namespace Bit.Api.Models.Public.Request;
|
||||
namespace Bit.Api.Dirt.Public.Models;
|
||||
|
||||
public class EventFilterRequestModel
|
||||
{
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Api.Models.Public.Response;
|
||||
namespace Bit.Api.Dirt.Public.Models;
|
||||
|
||||
/// <summary>
|
||||
/// An event log.
|
||||
@@ -47,6 +47,7 @@ public class AccountsKeyManagementController : Controller
|
||||
_webauthnKeyValidator;
|
||||
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
|
||||
private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery;
|
||||
private readonly ISetKeyConnectorKeyCommand _setKeyConnectorKeyCommand;
|
||||
|
||||
public AccountsKeyManagementController(IUserService userService,
|
||||
IFeatureService featureService,
|
||||
@@ -62,8 +63,10 @@ public class AccountsKeyManagementController : Controller
|
||||
emergencyAccessValidator,
|
||||
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
|
||||
organizationUserValidator,
|
||||
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator,
|
||||
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator)
|
||||
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
|
||||
webAuthnKeyValidator,
|
||||
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator,
|
||||
ISetKeyConnectorKeyCommand setKeyConnectorKeyCommand)
|
||||
{
|
||||
_userService = userService;
|
||||
_featureService = featureService;
|
||||
@@ -79,6 +82,7 @@ public class AccountsKeyManagementController : Controller
|
||||
_webauthnKeyValidator = webAuthnKeyValidator;
|
||||
_deviceValidator = deviceValidator;
|
||||
_keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery;
|
||||
_setKeyConnectorKeyCommand = setKeyConnectorKeyCommand;
|
||||
}
|
||||
|
||||
[HttpPost("key-management/regenerate-keys")]
|
||||
@@ -146,18 +150,28 @@ public class AccountsKeyManagementController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
|
||||
if (result.Succeeded)
|
||||
if (model.IsV2Request())
|
||||
{
|
||||
return;
|
||||
// V2 account registration
|
||||
await _setKeyConnectorKeyCommand.SetKeyConnectorKeyForUserAsync(user, model.ToKeyConnectorKeysData());
|
||||
}
|
||||
|
||||
foreach (var error in result.Errors)
|
||||
else
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
// V1 account registration
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new BadRequestException(ModelState);
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
|
||||
throw new BadRequestException(ModelState);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("convert-to-key-connector")]
|
||||
|
||||
@@ -1,36 +1,112 @@
|
||||
// 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 Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||
|
||||
public class SetKeyConnectorKeyRequestModel
|
||||
public class SetKeyConnectorKeyRequestModel : IValidatableObject
|
||||
{
|
||||
[Required]
|
||||
public string Key { get; set; }
|
||||
[Required]
|
||||
public KeysRequestModel Keys { get; set; }
|
||||
[Required]
|
||||
public KdfType Kdf { get; set; }
|
||||
[Required]
|
||||
public int KdfIterations { get; set; }
|
||||
public int? KdfMemory { get; set; }
|
||||
public int? KdfParallelism { get; set; }
|
||||
[Required]
|
||||
public string OrgIdentifier { get; set; }
|
||||
// TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
[Obsolete("Use KeyConnectorKeyWrappedUserKey instead")]
|
||||
public string? Key { get; set; }
|
||||
|
||||
[Obsolete("Use AccountKeys instead")]
|
||||
public KeysRequestModel? Keys { get; set; }
|
||||
[Obsolete("Not used anymore")]
|
||||
public KdfType? Kdf { get; set; }
|
||||
[Obsolete("Not used anymore")]
|
||||
public int? KdfIterations { get; set; }
|
||||
[Obsolete("Not used anymore")]
|
||||
public int? KdfMemory { get; set; }
|
||||
[Obsolete("Not used anymore")]
|
||||
public int? KdfParallelism { get; set; }
|
||||
|
||||
[EncryptedString]
|
||||
public string? KeyConnectorKeyWrappedUserKey { get; set; }
|
||||
public AccountKeysRequestModel? AccountKeys { get; set; }
|
||||
|
||||
[Required]
|
||||
public required string OrgIdentifier { get; init; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (IsV2Request())
|
||||
{
|
||||
// V2 registration
|
||||
yield break;
|
||||
}
|
||||
|
||||
// V1 registration
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
if (string.IsNullOrEmpty(Key))
|
||||
{
|
||||
yield return new ValidationResult("Key must be supplied.");
|
||||
}
|
||||
|
||||
if (Keys == null)
|
||||
{
|
||||
yield return new ValidationResult("Keys must be supplied.");
|
||||
}
|
||||
|
||||
if (Kdf == null)
|
||||
{
|
||||
yield return new ValidationResult("Kdf must be supplied.");
|
||||
}
|
||||
|
||||
if (KdfIterations == null)
|
||||
{
|
||||
yield return new ValidationResult("KdfIterations must be supplied.");
|
||||
}
|
||||
|
||||
if (Kdf == KdfType.Argon2id)
|
||||
{
|
||||
if (KdfMemory == null)
|
||||
{
|
||||
yield return new ValidationResult("KdfMemory must be supplied when Kdf is Argon2id.");
|
||||
}
|
||||
|
||||
if (KdfParallelism == null)
|
||||
{
|
||||
yield return new ValidationResult("KdfParallelism must be supplied when Kdf is Argon2id.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsV2Request()
|
||||
{
|
||||
return !string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) && AccountKeys != null;
|
||||
}
|
||||
|
||||
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
public User ToUser(User existingUser)
|
||||
{
|
||||
existingUser.Kdf = Kdf;
|
||||
existingUser.KdfIterations = KdfIterations;
|
||||
existingUser.Kdf = Kdf!.Value;
|
||||
existingUser.KdfIterations = KdfIterations!.Value;
|
||||
existingUser.KdfMemory = KdfMemory;
|
||||
existingUser.KdfParallelism = KdfParallelism;
|
||||
existingUser.Key = Key;
|
||||
Keys.ToUser(existingUser);
|
||||
Keys!.ToUser(existingUser);
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
public KeyConnectorKeysData ToKeyConnectorKeysData()
|
||||
{
|
||||
// TODO remove validation with https://bitwarden.atlassian.net/browse/PM-27328
|
||||
if (string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) || AccountKeys == null)
|
||||
{
|
||||
throw new BadRequestException("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.");
|
||||
}
|
||||
|
||||
return new KeyConnectorKeysData
|
||||
{
|
||||
KeyConnectorKeyWrappedUserKey = KeyConnectorKeyWrappedUserKey,
|
||||
AccountKeys = AccountKeys,
|
||||
OrgIdentifier = OrgIdentifier
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class SendRotationValidator : IRotationValidator<IEnumerable<SendWithIdRe
|
||||
throw new BadRequestException("All existing sends must be included in the rotation.");
|
||||
}
|
||||
|
||||
result.Add(send.ToSend(existing, _sendAuthorizationService));
|
||||
result.Add(send.UpdateSend(existing, _sendAuthorizationService));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Api.AdminConsole.Public.Models.Response;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Data;
|
||||
@@ -13,6 +14,12 @@ namespace Bit.Api.Models.Public.Response;
|
||||
/// </summary>
|
||||
public class CollectionResponseModel : CollectionBaseModel, IResponseModel
|
||||
{
|
||||
[JsonConstructor]
|
||||
public CollectionResponseModel()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public CollectionResponseModel(Collection collection, IEnumerable<CollectionAccessSelection> groups)
|
||||
{
|
||||
if (collection == null)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Services;
|
||||
@@ -45,7 +46,8 @@ public class ConfigResponseModel : ResponseModel
|
||||
Sso = globalSettings.BaseServiceUri.Sso
|
||||
};
|
||||
FeatureStates = featureService.GetAll();
|
||||
Push = PushSettings.Build(globalSettings);
|
||||
var webPushEnabled = FeatureStates.TryGetValue(FeatureFlagKeys.WebPush, out var webPushEnabledValue) ? (bool)webPushEnabledValue : false;
|
||||
Push = PushSettings.Build(webPushEnabled, globalSettings);
|
||||
Settings = new ServerSettingsResponseModel
|
||||
{
|
||||
DisableUserRegistration = globalSettings.DisableUserRegistration
|
||||
@@ -74,9 +76,9 @@ public class PushSettings
|
||||
public PushTechnologyType PushTechnology { get; private init; }
|
||||
public string VapidPublicKey { get; private init; }
|
||||
|
||||
public static PushSettings Build(IGlobalSettings globalSettings)
|
||||
public static PushSettings Build(bool webPushEnabled, IGlobalSettings globalSettings)
|
||||
{
|
||||
var vapidPublicKey = globalSettings.WebPush.VapidPublicKey;
|
||||
var vapidPublicKey = webPushEnabled ? globalSettings.WebPush.VapidPublicKey : null;
|
||||
var pushTechnology = vapidPublicKey != null ? PushTechnologyType.WebPush : PushTechnologyType.SignalR;
|
||||
return new()
|
||||
{
|
||||
|
||||
@@ -65,10 +65,11 @@ public class CollectionsController : Controller
|
||||
[ProducesResponseType(typeof(ListResponseModel<CollectionResponseModel>), (int)HttpStatusCode.OK)]
|
||||
public async Task<IActionResult> List()
|
||||
{
|
||||
var collections = await _collectionRepository.GetManySharedCollectionsByOrganizationIdAsync(
|
||||
_currentContext.OrganizationId.Value);
|
||||
// TODO: Get all CollectionGroup associations for the organization and marry them up here for the response.
|
||||
var collectionResponses = collections.Select(c => new CollectionResponseModel(c, null));
|
||||
var collections = await _collectionRepository.GetManyByOrganizationIdWithAccessAsync(_currentContext.OrganizationId.Value);
|
||||
|
||||
var collectionResponses = collections.Select(c =>
|
||||
new CollectionResponseModel(c.Item1, c.Item2.Groups));
|
||||
|
||||
var response = new ListResponseModel<CollectionResponseModel>(collectionResponses);
|
||||
return new JsonResult(response);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Bit.Api.Tools.Authorization;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -21,7 +20,6 @@ public class OrganizationExportController : Controller
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public OrganizationExportController(
|
||||
IUserService userService,
|
||||
@@ -36,7 +34,6 @@ public class OrganizationExportController : Controller
|
||||
_authorizationService = authorizationService;
|
||||
_organizationCiphersQuery = organizationCiphersQuery;
|
||||
_collectionRepository = collectionRepository;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[HttpGet("export")]
|
||||
@@ -46,33 +43,20 @@ public class OrganizationExportController : Controller
|
||||
VaultExportOperations.ExportWholeVault);
|
||||
var canExportManaged = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId),
|
||||
VaultExportOperations.ExportManagedCollections);
|
||||
var createDefaultLocationEnabled = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation);
|
||||
|
||||
if (canExportAll.Succeeded)
|
||||
{
|
||||
if (createDefaultLocationEnabled)
|
||||
{
|
||||
var allOrganizationCiphers =
|
||||
await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(
|
||||
organizationId);
|
||||
var allOrganizationCiphers =
|
||||
await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(
|
||||
organizationId);
|
||||
|
||||
var allCollections = await _collectionRepository
|
||||
.GetManySharedCollectionsByOrganizationIdAsync(
|
||||
organizationId);
|
||||
var allCollections = await _collectionRepository
|
||||
.GetManySharedCollectionsByOrganizationIdAsync(
|
||||
organizationId);
|
||||
|
||||
|
||||
return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections,
|
||||
_globalSettings));
|
||||
}
|
||||
else
|
||||
{
|
||||
var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId);
|
||||
|
||||
var allCollections = await _collectionRepository.GetManyByOrganizationIdAsync(organizationId);
|
||||
|
||||
return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections,
|
||||
_globalSettings));
|
||||
}
|
||||
return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections,
|
||||
_globalSettings));
|
||||
}
|
||||
|
||||
if (canExportManaged.Succeeded)
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using Azure.Messaging.EventGrid;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Tools.Models.Request;
|
||||
@@ -16,6 +13,7 @@ using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
using Bit.Core.Tools.SendFeatures;
|
||||
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
|
||||
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -33,6 +31,9 @@ public class SendsController : Controller
|
||||
private readonly ISendFileStorageService _sendFileStorageService;
|
||||
private readonly IAnonymousSendCommand _anonymousSendCommand;
|
||||
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
|
||||
|
||||
private readonly ISendOwnerQuery _sendOwnerQuery;
|
||||
|
||||
private readonly ILogger<SendsController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
@@ -42,6 +43,7 @@ public class SendsController : Controller
|
||||
ISendAuthorizationService sendAuthorizationService,
|
||||
IAnonymousSendCommand anonymousSendCommand,
|
||||
INonAnonymousSendCommand nonAnonymousSendCommand,
|
||||
ISendOwnerQuery sendOwnerQuery,
|
||||
ISendFileStorageService sendFileStorageService,
|
||||
ILogger<SendsController> logger,
|
||||
GlobalSettings globalSettings)
|
||||
@@ -51,6 +53,7 @@ public class SendsController : Controller
|
||||
_sendAuthorizationService = sendAuthorizationService;
|
||||
_anonymousSendCommand = anonymousSendCommand;
|
||||
_nonAnonymousSendCommand = nonAnonymousSendCommand;
|
||||
_sendOwnerQuery = sendOwnerQuery;
|
||||
_sendFileStorageService = sendFileStorageService;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
@@ -70,7 +73,11 @@ public class SendsController : Controller
|
||||
|
||||
var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
|
||||
var send = await _sendRepository.GetByIdAsync(guid);
|
||||
SendAccessResult sendAuthResult =
|
||||
if (send == null)
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
var sendAuthResult =
|
||||
await _sendAuthorizationService.AccessAsync(send, model.Password);
|
||||
if (sendAuthResult.Equals(SendAccessResult.PasswordRequired))
|
||||
{
|
||||
@@ -86,7 +93,7 @@ public class SendsController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var sendResponse = new SendAccessResponseModel(send, _globalSettings);
|
||||
var sendResponse = new SendAccessResponseModel(send);
|
||||
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
|
||||
{
|
||||
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
|
||||
@@ -181,33 +188,29 @@ public class SendsController : Controller
|
||||
[HttpGet("{id}")]
|
||||
public async Task<SendResponseModel> Get(string id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null || send.UserId != userId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return new SendResponseModel(send, _globalSettings);
|
||||
var sendId = new Guid(id);
|
||||
var send = await _sendOwnerQuery.Get(sendId, User);
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<SendResponseModel>> GetAll()
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var sends = await _sendRepository.GetManyByUserIdAsync(userId);
|
||||
var responses = sends.Select(s => new SendResponseModel(s, _globalSettings));
|
||||
return new ListResponseModel<SendResponseModel>(responses);
|
||||
var sends = await _sendOwnerQuery.GetOwned(User);
|
||||
var responses = sends.Select(s => new SendResponseModel(s));
|
||||
var result = new ListResponseModel<SendResponseModel>(responses);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[HttpPost("")]
|
||||
public async Task<SendResponseModel> Post([FromBody] SendRequestModel model)
|
||||
{
|
||||
model.ValidateCreation();
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var send = model.ToSend(userId, _sendAuthorizationService);
|
||||
await _nonAnonymousSendCommand.SaveSendAsync(send);
|
||||
return new SendResponseModel(send, _globalSettings);
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[HttpPost("file/v2")]
|
||||
@@ -229,27 +232,27 @@ public class SendsController : Controller
|
||||
}
|
||||
|
||||
model.ValidateCreation();
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var (send, data) = model.ToSend(userId, model.File.FileName, _sendAuthorizationService);
|
||||
var uploadUrl = await _nonAnonymousSendCommand.SaveFileSendAsync(send, data, model.FileLength.Value);
|
||||
return new SendFileUploadDataResponseModel
|
||||
{
|
||||
Url = uploadUrl,
|
||||
FileUploadType = _sendFileStorageService.FileUploadType,
|
||||
SendResponse = new SendResponseModel(send, _globalSettings)
|
||||
SendResponse = new SendResponseModel(send)
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("{id}/file/{fileId}")]
|
||||
public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, string fileId)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var sendId = new Guid(id);
|
||||
var send = await _sendRepository.GetByIdAsync(sendId);
|
||||
var fileData = JsonSerializer.Deserialize<SendFileData>(send?.Data);
|
||||
var fileData = JsonSerializer.Deserialize<SendFileData>(send?.Data ?? string.Empty);
|
||||
|
||||
if (send == null || send.Type != SendType.File || (send.UserId.HasValue && send.UserId.Value != userId) ||
|
||||
!send.UserId.HasValue || fileData.Id != fileId || fileData.Validated)
|
||||
!send.UserId.HasValue || fileData?.Id != fileId || fileData.Validated)
|
||||
{
|
||||
// Not found if Send isn't found, user doesn't have access, request is faulty,
|
||||
// or we've already validated the file. This last is to emulate create-only blob permissions for Azure
|
||||
@@ -260,7 +263,7 @@ public class SendsController : Controller
|
||||
{
|
||||
Url = await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId),
|
||||
FileUploadType = _sendFileStorageService.FileUploadType,
|
||||
SendResponse = new SendResponseModel(send, _globalSettings),
|
||||
SendResponse = new SendResponseModel(send),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -270,12 +273,16 @@ public class SendsController : Controller
|
||||
[DisableFormValueModelBinding]
|
||||
public async Task PostFileForExistingSend(string id, string fileId)
|
||||
{
|
||||
if (!Request?.ContentType.Contains("multipart/") ?? true)
|
||||
if (!Request?.ContentType?.Contains("multipart/") ?? true)
|
||||
{
|
||||
throw new BadRequestException("Invalid content.");
|
||||
}
|
||||
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null)
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
await Request.GetFileAsync(async (stream) =>
|
||||
{
|
||||
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
|
||||
@@ -286,36 +293,39 @@ public class SendsController : Controller
|
||||
public async Task<SendResponseModel> Put(string id, [FromBody] SendRequestModel model)
|
||||
{
|
||||
model.ValidateEdit();
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null || send.UserId != userId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await _nonAnonymousSendCommand.SaveSendAsync(model.ToSend(send, _sendAuthorizationService));
|
||||
return new SendResponseModel(send, _globalSettings);
|
||||
await _nonAnonymousSendCommand.SaveSendAsync(model.UpdateSend(send, _sendAuthorizationService));
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[HttpPut("{id}/remove-password")]
|
||||
public async Task<SendResponseModel> PutRemovePassword(string id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null || send.UserId != userId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// This endpoint exists because PUT preserves existing Password/Emails when not provided.
|
||||
// This allows clients to update other fields without re-submitting sensitive auth data.
|
||||
send.Password = null;
|
||||
send.AuthType = AuthType.None;
|
||||
await _nonAnonymousSendCommand.SaveSendAsync(send);
|
||||
return new SendResponseModel(send, _globalSettings);
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task Delete(string id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null || send.UserId != userId)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Bit.Api.Tools.Utilities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
@@ -10,35 +11,119 @@ using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
using static System.StringSplitOptions;
|
||||
|
||||
namespace Bit.Api.Tools.Models.Request;
|
||||
|
||||
/// <summary>
|
||||
/// A send request issued by a Bitwarden client
|
||||
/// </summary>
|
||||
public class SendRequestModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether the send contains text or file data.
|
||||
/// </summary>
|
||||
public SendType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the authentication method required to access this Send.
|
||||
/// </summary>
|
||||
public AuthType? AuthType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Estimated length of the file accompanying the send. <see langword="null"/> when
|
||||
/// <see cref="Type"/> is <see cref="SendType.Text"/>.
|
||||
/// </summary>
|
||||
public long? FileLength { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Label for the send.
|
||||
/// </summary>
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Notes for the send. This is only visible to the owner of the send.
|
||||
/// </summary>
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A base64-encoded byte array containing the Send's encryption key. This key is
|
||||
/// also provided to send recipients in the Send's URL.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[EncryptedString]
|
||||
[EncryptedStringLength(1000)]
|
||||
public string Key { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The maximum number of times a send can be accessed before it expires.
|
||||
/// When this value is <see langword="null" />, there is no limit.
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int? MaxAccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date after which a send cannot be accessed. When this value is
|
||||
/// <see langword="null"/>, there is no expiration date.
|
||||
/// </summary>
|
||||
public DateTime? ExpirationDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date after which a send may be automatically deleted from the server.
|
||||
/// When this is <see langword="null" />, the send may be deleted after it has
|
||||
/// exceeded the global send timeout limit.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public DateTime? DeletionDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contains file metadata uploaded with the send.
|
||||
/// The file content is uploaded separately.
|
||||
/// </summary>
|
||||
public SendFileModel File { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contains text data uploaded with the send.
|
||||
/// </summary>
|
||||
public SendTextModel Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded byte array of a password hash that grants access to the send.
|
||||
/// Mutually exclusive with <see cref="Emails"/>.
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated list of emails that may access the send using OTP
|
||||
/// authentication. Mutually exclusive with <see cref="Password"/>.
|
||||
/// </summary>
|
||||
[StringLength(4000)]
|
||||
public string Emails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/>, send access is disabled.
|
||||
/// Defaults to <see langword="false"/>.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool? Disabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/> send access hides the user's email address
|
||||
/// and displays a confirmation message instead. Defaults to <see langword="false"/>.
|
||||
/// </summary>
|
||||
public bool? HideEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Transforms the request into a send object.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user that owns the send.</param>
|
||||
/// <param name="sendAuthorizationService">Hashes the send password.</param>
|
||||
/// <returns>The send object</returns>
|
||||
public Send ToSend(Guid userId, ISendAuthorizationService sendAuthorizationService)
|
||||
{
|
||||
var send = new Send
|
||||
@@ -46,12 +131,21 @@ public class SendRequestModel
|
||||
Type = Type,
|
||||
UserId = (Guid?)userId
|
||||
};
|
||||
ToSend(send, sendAuthorizationService);
|
||||
send = UpdateSend(send, sendAuthorizationService);
|
||||
return send;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms the request into a send object and file data.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user that owns the send.</param>
|
||||
/// <param name="fileName">Name of the file uploaded with the send.</param>
|
||||
/// <param name="sendAuthorizationService">Hashes the send password.</param>
|
||||
/// <returns>The send object and file data.</returns>
|
||||
public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendAuthorizationService sendAuthorizationService)
|
||||
{
|
||||
// FIXME: This method does two things: creates a send and a send file data.
|
||||
// It should only do one thing.
|
||||
var send = ToSendBase(new Send
|
||||
{
|
||||
Type = Type,
|
||||
@@ -61,7 +155,13 @@ public class SendRequestModel
|
||||
return (send, data);
|
||||
}
|
||||
|
||||
public Send ToSend(Send existingSend, ISendAuthorizationService sendAuthorizationService)
|
||||
/// <summary>
|
||||
/// Update a send object with request content
|
||||
/// </summary>
|
||||
/// <param name="existingSend">The send to update</param>
|
||||
/// <param name="sendAuthorizationService">Hashes the send password.</param>
|
||||
/// <returns>The send object</returns>
|
||||
public Send UpdateSend(Send existingSend, ISendAuthorizationService sendAuthorizationService)
|
||||
{
|
||||
existingSend = ToSendBase(existingSend, sendAuthorizationService);
|
||||
switch (existingSend.Type)
|
||||
@@ -81,6 +181,12 @@ public class SendRequestModel
|
||||
return existingSend;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the request is internally consistent for send creation.
|
||||
/// </summary>
|
||||
/// <exception cref="BadRequestException">
|
||||
/// Thrown when the send's expiration date has already expired.
|
||||
/// </exception>
|
||||
public void ValidateCreation()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
@@ -94,6 +200,13 @@ public class SendRequestModel
|
||||
ValidateEdit();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the request is internally consistent for send administration.
|
||||
/// </summary>
|
||||
/// <exception cref="BadRequestException">
|
||||
/// Thrown when the send's deletion date has already expired or when its
|
||||
/// expiration occurs after its deletion.
|
||||
/// </exception>
|
||||
public void ValidateEdit()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
@@ -134,12 +247,30 @@ public class SendRequestModel
|
||||
existingSend.ExpirationDate = ExpirationDate;
|
||||
existingSend.DeletionDate = DeletionDate.Value;
|
||||
existingSend.MaxAccessCount = MaxAccessCount;
|
||||
if (!string.IsNullOrWhiteSpace(Password))
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Emails))
|
||||
{
|
||||
// normalize encoding
|
||||
var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries);
|
||||
existingSend.Emails = string.Join(",", emails);
|
||||
existingSend.Password = null;
|
||||
existingSend.AuthType = Core.Tools.Enums.AuthType.Email;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
existingSend.Password = authorizationService.HashPassword(Password);
|
||||
existingSend.Emails = null;
|
||||
existingSend.AuthType = Core.Tools.Enums.AuthType.Password;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Neither Password nor Emails provided - preserve existing values and infer AuthType
|
||||
existingSend.AuthType = SendUtilities.InferAuthType(existingSend);
|
||||
}
|
||||
|
||||
existingSend.Disabled = Disabled.GetValueOrDefault();
|
||||
existingSend.HideEmail = HideEmail.GetValueOrDefault();
|
||||
|
||||
return existingSend;
|
||||
}
|
||||
|
||||
@@ -149,8 +280,15 @@ public class SendRequestModel
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A send request issued by a Bitwarden client
|
||||
/// </summary>
|
||||
public class SendWithIdRequestModel : SendRequestModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Identifies the send. When this is <see langword="null" />, the client is requesting
|
||||
/// a new send.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid? Id { get; set; }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
@@ -11,9 +10,22 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.Tools.Models.Response;
|
||||
|
||||
/// <summary>
|
||||
/// A response issued to a Bitwarden client in response to access operations.
|
||||
/// </summary>
|
||||
public class SendAccessResponseModel : ResponseModel
|
||||
{
|
||||
public SendAccessResponseModel(Send send, GlobalSettings globalSettings)
|
||||
/// <summary>
|
||||
/// Instantiates a send access response model
|
||||
/// </summary>
|
||||
/// <param name="send">Content to transmit to the client.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="send"/> is <see langword="null" />
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when <paramref name="send" /> has an invalid <see cref="Send.Type"/>.
|
||||
/// </exception>
|
||||
public SendAccessResponseModel(Send send)
|
||||
: base("send-access")
|
||||
{
|
||||
if (send == null)
|
||||
@@ -23,6 +35,7 @@ public class SendAccessResponseModel : ResponseModel
|
||||
|
||||
Id = CoreHelpers.Base64UrlEncode(send.Id.ToByteArray());
|
||||
Type = send.Type;
|
||||
AuthType = send.AuthType;
|
||||
|
||||
SendData sendData;
|
||||
switch (send.Type)
|
||||
@@ -45,11 +58,52 @@ public class SendAccessResponseModel : ResponseModel
|
||||
ExpirationDate = send.ExpirationDate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the send in a send URL
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the send contains text or file data.
|
||||
/// </summary>
|
||||
public SendType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the authentication method required to access this Send.
|
||||
/// </summary>
|
||||
public AuthType? AuthType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Label for the send. This is only visible to the owner of the send.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This field contains a base64-encoded byte array. The array contains
|
||||
/// the E2E-encrypted encrypted content.
|
||||
/// </remarks>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Describes the file attached to the send.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// File content is downloaded separately using
|
||||
/// <see cref="Bit.Api.Tools.Controllers.SendsController.GetSendFileDownloadData" />
|
||||
/// </remarks>
|
||||
public SendFileModel File { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contains text data uploaded with the send.
|
||||
/// </summary>
|
||||
public SendTextModel Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date after which a send cannot be accessed. When this value is
|
||||
/// <see langword="null"/>, there is no expiration date.
|
||||
/// </summary>
|
||||
public DateTime? ExpirationDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates the person that created the send to the accessor.
|
||||
/// </summary>
|
||||
public string CreatorIdentifier { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
#nullable disable
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Api.Tools.Utilities;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
@@ -11,9 +11,23 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.Tools.Models.Response;
|
||||
|
||||
/// <summary>
|
||||
/// A response issued to a Bitwarden client in response to ownership operations.
|
||||
/// </summary>
|
||||
/// <seealso cref="SendAccessResponseModel" />
|
||||
public class SendResponseModel : ResponseModel
|
||||
{
|
||||
public SendResponseModel(Send send, GlobalSettings globalSettings)
|
||||
/// <summary>
|
||||
/// Instantiates a send response model
|
||||
/// </summary>
|
||||
/// <param name="send">Content to transmit to the client.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="send"/> is <see langword="null" />
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when <paramref name="send" /> has an invalid <see cref="Send.Type"/>.
|
||||
/// </exception>
|
||||
public SendResponseModel(Send send)
|
||||
: base("send")
|
||||
{
|
||||
if (send == null)
|
||||
@@ -24,6 +38,7 @@ public class SendResponseModel : ResponseModel
|
||||
Id = send.Id;
|
||||
AccessId = CoreHelpers.Base64UrlEncode(send.Id.ToByteArray());
|
||||
Type = send.Type;
|
||||
AuthType = send.AuthType ?? SendUtilities.InferAuthType(send);
|
||||
Key = send.Key;
|
||||
MaxAccessCount = send.MaxAccessCount;
|
||||
AccessCount = send.AccessCount;
|
||||
@@ -31,6 +46,7 @@ public class SendResponseModel : ResponseModel
|
||||
ExpirationDate = send.ExpirationDate;
|
||||
DeletionDate = send.DeletionDate;
|
||||
Password = send.Password;
|
||||
Emails = send.Emails;
|
||||
Disabled = send.Disabled;
|
||||
HideEmail = send.HideEmail.GetValueOrDefault();
|
||||
|
||||
@@ -55,20 +71,113 @@ public class SendResponseModel : ResponseModel
|
||||
Notes = sendData.Notes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the send to its owner
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the send in a send URL
|
||||
/// </summary>
|
||||
public string AccessId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the send contains text or file data.
|
||||
/// </summary>
|
||||
public SendType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the authentication method required to access this Send.
|
||||
/// </summary>
|
||||
public AuthType? AuthType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Label for the send.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This field contains a base64-encoded byte array. The array contains
|
||||
/// the E2E-encrypted encrypted content.
|
||||
/// </remarks>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Notes for the send. This is only visible to the owner of the send.
|
||||
/// This field is encrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This field contains a base64-encoded byte array. The array contains
|
||||
/// the E2E-encrypted encrypted content.
|
||||
/// </remarks>
|
||||
public string Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contains file metadata uploaded with the send.
|
||||
/// The file content is uploaded separately.
|
||||
/// </summary>
|
||||
public SendFileModel File { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Contains text data uploaded with the send.
|
||||
/// </summary>
|
||||
public SendTextModel Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A base64-encoded byte array containing the Send's encryption key.
|
||||
/// It's also provided to send recipients in the Send's URL.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This field contains a base64-encoded byte array. The array contains
|
||||
/// the E2E-encrypted content.
|
||||
/// </remarks>
|
||||
public string Key { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The maximum number of times a send can be accessed before it expires.
|
||||
/// When this value is <see langword="null" />, there is no limit.
|
||||
/// </summary>
|
||||
public int? MaxAccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of times a send has been accessed since it was created.
|
||||
/// </summary>
|
||||
public int AccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded byte array of a password hash that grants access to the send.
|
||||
/// Mutually exclusive with <see cref="Emails"/>.
|
||||
/// </summary>
|
||||
public string Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated list of emails that may access the send using OTP
|
||||
/// authentication. Mutually exclusive with <see cref="Password"/>.
|
||||
/// </summary>
|
||||
public string Emails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/>, send access is disabled.
|
||||
/// </summary>
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The last time this send's data changed.
|
||||
/// </summary>
|
||||
public DateTime RevisionDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date after which a send cannot be accessed. When this value is
|
||||
/// <see langword="null"/>, there is no expiration date.
|
||||
/// </summary>
|
||||
public DateTime? ExpirationDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The date after which a send may be automatically deleted from the server.
|
||||
/// </summary>
|
||||
public DateTime DeletionDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When <see langword="true"/> send access hides the user's email address
|
||||
/// and displays a confirmation message instead.
|
||||
/// </summary>
|
||||
public bool HideEmail { get; set; }
|
||||
}
|
||||
|
||||
23
src/Api/Tools/Utilities/InferAuthType.cs
Normal file
23
src/Api/Tools/Utilities/InferAuthType.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace Bit.Api.Tools.Utilities;
|
||||
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
|
||||
public class SendUtilities
|
||||
{
|
||||
public static AuthType InferAuthType(Send send)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(send.Password))
|
||||
{
|
||||
return AuthType.Password;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(send.Emails))
|
||||
{
|
||||
return AuthType.Email;
|
||||
}
|
||||
|
||||
return AuthType.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Bit.Api.Models.Public.Request;
|
||||
using Bit.Api.Dirt.Public.Models;
|
||||
using Bit.Api.Models.Public.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Services;
|
||||
@@ -49,7 +49,7 @@ public static class EventDiagnosticLogger
|
||||
this ILogger logger,
|
||||
IFeatureService featureService,
|
||||
Guid organizationId,
|
||||
IEnumerable<Bit.Api.Models.Response.EventResponseModel> data,
|
||||
IEnumerable<Dirt.Models.Response.EventResponseModel> data,
|
||||
string? continuationToken,
|
||||
DateTime? queryStart = null,
|
||||
DateTime? queryEnd = null)
|
||||
|
||||
@@ -10,7 +10,6 @@ using Bit.Api.Utilities;
|
||||
using Bit.Api.Vault.Models.Request;
|
||||
using Bit.Api.Vault.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@@ -43,7 +42,6 @@ public class CiphersController : Controller
|
||||
private readonly ICipherService _cipherService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IAttachmentStorageService _attachmentStorageService;
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ILogger<CiphersController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
@@ -52,7 +50,6 @@ public class CiphersController : Controller
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IArchiveCiphersCommand _archiveCiphersCommand;
|
||||
private readonly IUnarchiveCiphersCommand _unarchiveCiphersCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public CiphersController(
|
||||
ICipherRepository cipherRepository,
|
||||
@@ -60,7 +57,6 @@ public class CiphersController : Controller
|
||||
ICipherService cipherService,
|
||||
IUserService userService,
|
||||
IAttachmentStorageService attachmentStorageService,
|
||||
IProviderService providerService,
|
||||
ICurrentContext currentContext,
|
||||
ILogger<CiphersController> logger,
|
||||
GlobalSettings globalSettings,
|
||||
@@ -68,15 +64,13 @@ public class CiphersController : Controller
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ICollectionRepository collectionRepository,
|
||||
IArchiveCiphersCommand archiveCiphersCommand,
|
||||
IUnarchiveCiphersCommand unarchiveCiphersCommand,
|
||||
IFeatureService featureService)
|
||||
IUnarchiveCiphersCommand unarchiveCiphersCommand)
|
||||
{
|
||||
_cipherRepository = cipherRepository;
|
||||
_collectionCipherRepository = collectionCipherRepository;
|
||||
_cipherService = cipherService;
|
||||
_userService = userService;
|
||||
_attachmentStorageService = attachmentStorageService;
|
||||
_providerService = providerService;
|
||||
_currentContext = currentContext;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
@@ -85,7 +79,6 @@ public class CiphersController : Controller
|
||||
_collectionRepository = collectionRepository;
|
||||
_archiveCiphersCommand = archiveCiphersCommand;
|
||||
_unarchiveCiphersCommand = unarchiveCiphersCommand;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@@ -344,8 +337,7 @@ public class CiphersController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
bool excludeDefaultUserCollections = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) && !includeMemberItems;
|
||||
var allOrganizationCiphers = excludeDefaultUserCollections
|
||||
var allOrganizationCiphers = !includeMemberItems
|
||||
?
|
||||
await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId)
|
||||
:
|
||||
@@ -911,7 +903,7 @@ public class CiphersController : Controller
|
||||
|
||||
[HttpPut("{id}/archive")]
|
||||
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
|
||||
public async Task<CipherMiniResponseModel> PutArchive(Guid id)
|
||||
public async Task<CipherResponseModel> PutArchive(Guid id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
|
||||
@@ -922,12 +914,16 @@ public class CiphersController : Controller
|
||||
throw new BadRequestException("Cipher was not archived. Ensure the provided ID is correct and you have permission to archive it.");
|
||||
}
|
||||
|
||||
return new CipherMiniResponseModel(archivedCipherOrganizationDetails.First(), _globalSettings, archivedCipherOrganizationDetails.First().OrganizationUseTotp);
|
||||
return new CipherResponseModel(archivedCipherOrganizationDetails.First(),
|
||||
await _userService.GetUserByPrincipalAsync(User),
|
||||
await _applicationCacheService.GetOrganizationAbilitiesAsync(),
|
||||
_globalSettings
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPut("archive")]
|
||||
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
|
||||
public async Task<ListResponseModel<CipherMiniResponseModel>> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model)
|
||||
public async Task<ListResponseModel<CipherResponseModel>> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model)
|
||||
{
|
||||
if (!_globalSettings.SelfHosted && model.Ids.Count() > 500)
|
||||
{
|
||||
@@ -935,6 +931,7 @@ public class CiphersController : Controller
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
|
||||
var cipherIdsToArchive = new HashSet<Guid>(model.Ids);
|
||||
|
||||
@@ -945,9 +942,14 @@ public class CiphersController : Controller
|
||||
throw new BadRequestException("No ciphers were archived. Ensure the provided IDs are correct and you have permission to archive them.");
|
||||
}
|
||||
|
||||
var responses = archivedCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
|
||||
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||
var responses = archivedCiphers.Select(c => new CipherResponseModel(c,
|
||||
user,
|
||||
organizationAbilities,
|
||||
_globalSettings
|
||||
));
|
||||
|
||||
return new ListResponseModel<CipherMiniResponseModel>(responses);
|
||||
return new ListResponseModel<CipherResponseModel>(responses);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
@@ -1109,7 +1111,7 @@ public class CiphersController : Controller
|
||||
|
||||
[HttpPut("{id}/unarchive")]
|
||||
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
|
||||
public async Task<CipherMiniResponseModel> PutUnarchive(Guid id)
|
||||
public async Task<CipherResponseModel> PutUnarchive(Guid id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
|
||||
@@ -1120,12 +1122,16 @@ public class CiphersController : Controller
|
||||
throw new BadRequestException("Cipher was not unarchived. Ensure the provided ID is correct and you have permission to archive it.");
|
||||
}
|
||||
|
||||
return new CipherMiniResponseModel(unarchivedCipherDetails.First(), _globalSettings, unarchivedCipherDetails.First().OrganizationUseTotp);
|
||||
return new CipherResponseModel(unarchivedCipherDetails.First(),
|
||||
await _userService.GetUserByPrincipalAsync(User),
|
||||
await _applicationCacheService.GetOrganizationAbilitiesAsync(),
|
||||
_globalSettings
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPut("unarchive")]
|
||||
[RequireFeature(FeatureFlagKeys.ArchiveVaultItems)]
|
||||
public async Task<ListResponseModel<CipherMiniResponseModel>> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model)
|
||||
public async Task<ListResponseModel<CipherResponseModel>> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model)
|
||||
{
|
||||
if (!_globalSettings.SelfHosted && model.Ids.Count() > 500)
|
||||
{
|
||||
@@ -1133,6 +1139,8 @@ public class CiphersController : Controller
|
||||
}
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
var user = await _userService.GetUserByPrincipalAsync(User);
|
||||
var organizationAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync();
|
||||
|
||||
var cipherIdsToUnarchive = new HashSet<Guid>(model.Ids);
|
||||
|
||||
@@ -1143,9 +1151,9 @@ public class CiphersController : Controller
|
||||
throw new BadRequestException("Ciphers were not unarchived. Ensure the provided ID is correct and you have permission to archive it.");
|
||||
}
|
||||
|
||||
var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
|
||||
var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherResponseModel(c, user, organizationAbilities, _globalSettings));
|
||||
|
||||
return new ListResponseModel<CipherMiniResponseModel>(responses);
|
||||
return new ListResponseModel<CipherResponseModel>(responses);
|
||||
}
|
||||
|
||||
[HttpPut("{id}/restore")]
|
||||
|
||||
@@ -80,6 +80,7 @@ public class CipherRequestModel
|
||||
{
|
||||
existingCipher.FolderId = string.IsNullOrWhiteSpace(FolderId) ? null : (Guid?)new Guid(FolderId);
|
||||
existingCipher.Favorite = Favorite;
|
||||
existingCipher.ArchivedDate = ArchivedDate;
|
||||
ToCipher(existingCipher);
|
||||
return existingCipher;
|
||||
}
|
||||
@@ -127,9 +128,9 @@ public class CipherRequestModel
|
||||
var userIdKey = userId.HasValue ? userId.ToString().ToUpperInvariant() : null;
|
||||
existingCipher.Reprompt = Reprompt;
|
||||
existingCipher.Key = Key;
|
||||
existingCipher.ArchivedDate = ArchivedDate;
|
||||
existingCipher.Folders = UpdateUserSpecificJsonField(existingCipher.Folders, userIdKey, FolderId);
|
||||
existingCipher.Favorites = UpdateUserSpecificJsonField(existingCipher.Favorites, userIdKey, Favorite);
|
||||
existingCipher.Archives = UpdateUserSpecificJsonField(existingCipher.Archives, userIdKey, ArchivedDate);
|
||||
|
||||
var hasAttachments2 = (Attachments2?.Count ?? 0) > 0;
|
||||
var hasAttachments = (Attachments?.Count ?? 0) > 0;
|
||||
|
||||
@@ -70,7 +70,6 @@ public class CipherMiniResponseModel : ResponseModel
|
||||
DeletedDate = cipher.DeletedDate;
|
||||
Reprompt = cipher.Reprompt.GetValueOrDefault(CipherRepromptType.None);
|
||||
Key = cipher.Key;
|
||||
ArchivedDate = cipher.ArchivedDate;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
@@ -111,7 +110,6 @@ public class CipherMiniResponseModel : ResponseModel
|
||||
public DateTime? DeletedDate { get; set; }
|
||||
public CipherRepromptType Reprompt { get; set; }
|
||||
public string Key { get; set; }
|
||||
public DateTime? ArchivedDate { get; set; }
|
||||
}
|
||||
|
||||
public class CipherResponseModel : CipherMiniResponseModel
|
||||
@@ -127,6 +125,7 @@ public class CipherResponseModel : CipherMiniResponseModel
|
||||
FolderId = cipher.FolderId;
|
||||
Favorite = cipher.Favorite;
|
||||
Edit = cipher.Edit;
|
||||
ArchivedDate = cipher.ArchivedDate;
|
||||
ViewPassword = cipher.ViewPassword;
|
||||
Permissions = new CipherPermissionsResponseModel(user, cipher, organizationAbilities);
|
||||
}
|
||||
@@ -135,6 +134,7 @@ public class CipherResponseModel : CipherMiniResponseModel
|
||||
public bool Favorite { get; set; }
|
||||
public bool Edit { get; set; }
|
||||
public bool ViewPassword { get; set; }
|
||||
public DateTime? ArchivedDate { get; set; }
|
||||
public CipherPermissionsResponseModel Permissions { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ public class SyncResponseModel() : ResponseModel("sync")
|
||||
c => new CollectionDetailsResponseModel(c)) ?? new List<CollectionDetailsResponseModel>();
|
||||
Domains = excludeDomains ? null : new DomainsResponseModel(user, false);
|
||||
Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List<PolicyResponseModel>();
|
||||
Sends = sends.Select(s => new SendResponseModel(s, globalSettings));
|
||||
Sends = sends.Select(s => new SendResponseModel(s));
|
||||
UserDecryption = new UserDecryptionResponseModel
|
||||
{
|
||||
MasterPasswordUnlock = user.HasMasterPassword()
|
||||
|
||||
@@ -9,10 +9,7 @@ public class BillingSettings
|
||||
public virtual string StripeWebhookKey { get; set; }
|
||||
public virtual string StripeWebhookSecret20250827Basil { get; set; }
|
||||
public virtual string AppleWebhookKey { get; set; }
|
||||
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();
|
||||
public virtual string FreshsalesApiKey { get; set; }
|
||||
public virtual PayPalSettings PayPal { get; set; } = new PayPalSettings();
|
||||
public virtual OnyxSettings Onyx { get; set; } = new OnyxSettings();
|
||||
|
||||
public class PayPalSettings
|
||||
{
|
||||
@@ -21,35 +18,4 @@ public class BillingSettings
|
||||
public virtual string WebhookKey { get; set; }
|
||||
}
|
||||
|
||||
public class FreshDeskSettings
|
||||
{
|
||||
public virtual string ApiKey { get; set; }
|
||||
public virtual string WebhookKey { get; set; }
|
||||
/// <summary>
|
||||
/// Indicates the data center region. Valid values are "US" and "EU"
|
||||
/// </summary>
|
||||
public virtual string Region { get; set; }
|
||||
public virtual string UserFieldName { get; set; }
|
||||
public virtual string OrgFieldName { get; set; }
|
||||
|
||||
public virtual bool RemoveNewlinesInReplies { get; set; } = false;
|
||||
public virtual string AutoReplyGreeting { get; set; } = string.Empty;
|
||||
public virtual string AutoReplySalutation { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class OnyxSettings
|
||||
{
|
||||
public virtual string ApiKey { get; set; }
|
||||
public virtual string BaseUrl { get; set; }
|
||||
public virtual string Path { get; set; }
|
||||
public virtual int PersonaId { get; set; }
|
||||
public virtual bool UseAnswerWithCitationModels { get; set; } = true;
|
||||
|
||||
public virtual SearchSettings SearchSettings { get; set; } = new SearchSettings();
|
||||
}
|
||||
public class SearchSettings
|
||||
{
|
||||
public virtual string RunSearch { get; set; } = "auto"; // "always", "never", "auto"
|
||||
public virtual bool RealTime { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
using Bit.Billing.Models;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Markdig;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Billing.Controllers;
|
||||
|
||||
[Route("freshdesk")]
|
||||
public class FreshdeskController : Controller
|
||||
{
|
||||
private readonly BillingSettings _billingSettings;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ILogger<FreshdeskController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public FreshdeskController(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOptions<BillingSettings> billingSettings,
|
||||
ILogger<FreshdeskController> logger,
|
||||
GlobalSettings globalSettings,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_billingSettings = billingSettings?.Value ?? throw new ArgumentNullException(nameof(billingSettings));
|
||||
_userRepository = userRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[HttpPost("webhook")]
|
||||
public async Task<IActionResult> PostWebhook([FromQuery, Required] string key,
|
||||
[FromBody, Required] FreshdeskWebhookModel model)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey))
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var ticketId = model.TicketId;
|
||||
var ticketContactEmail = model.TicketContactEmail;
|
||||
var ticketTags = model.TicketTags;
|
||||
if (string.IsNullOrWhiteSpace(ticketId) || string.IsNullOrWhiteSpace(ticketContactEmail))
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var updateBody = new Dictionary<string, object>();
|
||||
var note = string.Empty;
|
||||
note += $"<li>Region: {_billingSettings.FreshDesk.Region}</li>";
|
||||
var customFields = new Dictionary<string, object>();
|
||||
var user = await _userRepository.GetByEmailAsync(ticketContactEmail);
|
||||
if (user == null)
|
||||
{
|
||||
note += $"<li>No user found: {ticketContactEmail}</li>";
|
||||
await CreateNote(ticketId, note);
|
||||
}
|
||||
|
||||
if (user != null)
|
||||
{
|
||||
var userLink = $"{_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}";
|
||||
note += $"<li>User, {user.Email}: {userLink}</li>";
|
||||
customFields.Add(_billingSettings.FreshDesk.UserFieldName, userLink);
|
||||
var tags = new HashSet<string>();
|
||||
if (user.Premium)
|
||||
{
|
||||
tags.Add("Premium");
|
||||
}
|
||||
var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id);
|
||||
|
||||
foreach (var org in orgs)
|
||||
{
|
||||
// Prevent org names from injecting any additional HTML
|
||||
var orgName = HttpUtility.HtmlEncode(org.Name);
|
||||
var orgNote = $"{orgName} ({org.Seats.GetValueOrDefault()}): " +
|
||||
$"{_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}";
|
||||
note += $"<li>Org, {orgNote}</li>";
|
||||
if (!customFields.Any(kvp => kvp.Key == _billingSettings.FreshDesk.OrgFieldName))
|
||||
{
|
||||
customFields.Add(_billingSettings.FreshDesk.OrgFieldName, orgNote);
|
||||
}
|
||||
else
|
||||
{
|
||||
customFields[_billingSettings.FreshDesk.OrgFieldName] += $"\n{orgNote}";
|
||||
}
|
||||
|
||||
var displayAttribute = GetAttribute<DisplayAttribute>(org.PlanType);
|
||||
var planName = displayAttribute?.Name?.Split(" ").FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(planName))
|
||||
{
|
||||
tags.Add(string.Format("Org: {0}", planName));
|
||||
}
|
||||
}
|
||||
if (tags.Any())
|
||||
{
|
||||
var tagsToUpdate = tags.ToList();
|
||||
if (!string.IsNullOrWhiteSpace(ticketTags))
|
||||
{
|
||||
var splitTicketTags = ticketTags.Split(',');
|
||||
for (var i = 0; i < splitTicketTags.Length; i++)
|
||||
{
|
||||
tagsToUpdate.Insert(i, splitTicketTags[i]);
|
||||
}
|
||||
}
|
||||
updateBody.Add("tags", tagsToUpdate);
|
||||
}
|
||||
|
||||
if (customFields.Any())
|
||||
{
|
||||
updateBody.Add("custom_fields", customFields);
|
||||
}
|
||||
var updateRequest = new HttpRequestMessage(HttpMethod.Put,
|
||||
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}", ticketId))
|
||||
{
|
||||
Content = JsonContent.Create(updateBody),
|
||||
};
|
||||
await CallFreshdeskApiAsync(updateRequest);
|
||||
await CreateNote(ticketId, note);
|
||||
}
|
||||
|
||||
return new OkResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Error processing freshdesk webhook.");
|
||||
return new BadRequestResult();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("webhook-onyx-ai")]
|
||||
public async Task<IActionResult> PostWebhookOnyxAi([FromQuery, Required] string key,
|
||||
[FromBody, Required] FreshdeskOnyxAiWebhookModel model)
|
||||
{
|
||||
// ensure that the key is from Freshdesk
|
||||
if (!IsValidRequestFromFreshdesk(key))
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
// if there is no description, then we don't send anything to onyx
|
||||
if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim()))
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// Get response from Onyx AI
|
||||
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
|
||||
|
||||
// the CallOnyxApi will return a null if we have an error response
|
||||
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
|
||||
{
|
||||
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
|
||||
JsonSerializer.Serialize(model),
|
||||
JsonSerializer.Serialize(onyxRequest),
|
||||
JsonSerializer.Serialize(onyxResponse));
|
||||
|
||||
return Ok(); // return ok so we don't retry
|
||||
}
|
||||
|
||||
// add the answer as a note to the ticket
|
||||
await AddAnswerNoteToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("webhook-onyx-ai-reply")]
|
||||
public async Task<IActionResult> PostWebhookOnyxAiReply([FromQuery, Required] string key,
|
||||
[FromBody, Required] FreshdeskOnyxAiWebhookModel model)
|
||||
{
|
||||
// NOTE:
|
||||
// at this time, this endpoint is a duplicate of `webhook-onyx-ai`
|
||||
// eventually, we will merge both endpoints into one webhook for Freshdesk
|
||||
|
||||
// ensure that the key is from Freshdesk
|
||||
if (!IsValidRequestFromFreshdesk(key) || !ModelState.IsValid)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
// if there is no description, then we don't send anything to onyx
|
||||
if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim()))
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// create the onyx `answer-with-citation` request
|
||||
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
|
||||
|
||||
// the CallOnyxApi will return a null if we have an error response
|
||||
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
|
||||
{
|
||||
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
|
||||
JsonSerializer.Serialize(model),
|
||||
JsonSerializer.Serialize(onyxRequest),
|
||||
JsonSerializer.Serialize(onyxResponse));
|
||||
|
||||
return Ok(); // return ok so we don't retry
|
||||
}
|
||||
|
||||
// add the reply to the ticket
|
||||
await AddReplyToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private bool IsValidRequestFromFreshdesk(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)
|
||||
|| !CoreHelpers.FixedTimeEquals(key, _billingSettings.FreshDesk.WebhookKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task CreateNote(string ticketId, string note)
|
||||
{
|
||||
var noteBody = new Dictionary<string, object>
|
||||
{
|
||||
{ "body", $"<ul>{note}</ul>" },
|
||||
{ "private", true }
|
||||
};
|
||||
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
|
||||
{
|
||||
Content = JsonContent.Create(noteBody),
|
||||
};
|
||||
await CallFreshdeskApiAsync(noteRequest);
|
||||
}
|
||||
|
||||
private async Task AddAnswerNoteToTicketAsync(string note, string ticketId)
|
||||
{
|
||||
// if there is no content, then we don't need to add a note
|
||||
if (string.IsNullOrWhiteSpace(note))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var noteBody = new Dictionary<string, object>
|
||||
{
|
||||
{ "body", $"<b>Onyx AI:</b><ul>{note}</ul>" },
|
||||
{ "private", true }
|
||||
};
|
||||
|
||||
var noteRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/notes", ticketId))
|
||||
{
|
||||
Content = JsonContent.Create(noteBody),
|
||||
};
|
||||
|
||||
var addNoteResponse = await CallFreshdeskApiAsync(noteRequest);
|
||||
if (addNoteResponse.StatusCode != System.Net.HttpStatusCode.Created)
|
||||
{
|
||||
_logger.LogError("Error adding note to Freshdesk ticket. Ticket Id: {0}. Status: {1}",
|
||||
ticketId, addNoteResponse.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddReplyToTicketAsync(string note, string ticketId)
|
||||
{
|
||||
// if there is no content, then we don't need to add a note
|
||||
if (string.IsNullOrWhiteSpace(note))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// convert note from markdown to html
|
||||
var htmlNote = note;
|
||||
try
|
||||
{
|
||||
var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
|
||||
htmlNote = Markdig.Markdown.ToHtml(note, pipeline);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error converting markdown to HTML for Freshdesk reply. Ticket Id: {0}. Note: {1}",
|
||||
ticketId, note);
|
||||
htmlNote = note; // fallback to the original note
|
||||
}
|
||||
|
||||
// clear out any new lines that Freshdesk doesn't like
|
||||
if (_billingSettings.FreshDesk.RemoveNewlinesInReplies)
|
||||
{
|
||||
htmlNote = htmlNote.Replace(Environment.NewLine, string.Empty);
|
||||
}
|
||||
|
||||
var replyBody = new FreshdeskReplyRequestModel
|
||||
{
|
||||
Body = $"{_billingSettings.FreshDesk.AutoReplyGreeting}{htmlNote}{_billingSettings.FreshDesk.AutoReplySalutation}",
|
||||
};
|
||||
|
||||
var replyRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/reply", ticketId))
|
||||
{
|
||||
Content = JsonContent.Create(replyBody),
|
||||
};
|
||||
|
||||
var addReplyResponse = await CallFreshdeskApiAsync(replyRequest);
|
||||
if (addReplyResponse.StatusCode != System.Net.HttpStatusCode.Created)
|
||||
{
|
||||
_logger.LogError("Error adding reply to Freshdesk ticket. Ticket Id: {0}. Status: {1}",
|
||||
ticketId, addReplyResponse.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var freshdeskAuthkey = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_billingSettings.FreshDesk.ApiKey}:X"));
|
||||
var httpClient = _httpClientFactory.CreateClient("FreshdeskApi");
|
||||
request.Headers.Add("Authorization", $"Basic {freshdeskAuthkey}");
|
||||
var response = await httpClient.SendAsync(request);
|
||||
if (response.StatusCode != System.Net.HttpStatusCode.TooManyRequests || retriedCount > 3)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (retriedCount > 3)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
await Task.Delay(30000 * (retriedCount + 1));
|
||||
return await CallFreshdeskApiAsync(request, retriedCount++);
|
||||
}
|
||||
|
||||
async Task<(OnyxRequestModel onyxRequest, OnyxResponseModel onyxResponse)> GetAnswerFromOnyx(FreshdeskOnyxAiWebhookModel model)
|
||||
{
|
||||
// TODO: remove the use of the deprecated answer-with-citation models after we are sure
|
||||
if (_billingSettings.Onyx.UseAnswerWithCitationModels)
|
||||
{
|
||||
var onyxRequest = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
|
||||
var onyxAnswerWithCitationRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
|
||||
{
|
||||
Content = JsonContent.Create(onyxRequest, mediaType: new MediaTypeHeaderValue("application/json")),
|
||||
};
|
||||
var onyxResponse = await CallOnyxApi<OnyxResponseModel>(onyxAnswerWithCitationRequest);
|
||||
return (onyxRequest, onyxResponse);
|
||||
}
|
||||
|
||||
var request = new OnyxSendMessageSimpleApiRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
|
||||
var onyxSimpleRequest = new HttpRequestMessage(HttpMethod.Post,
|
||||
string.Format("{0}{1}", _billingSettings.Onyx.BaseUrl, _billingSettings.Onyx.Path))
|
||||
{
|
||||
Content = JsonContent.Create(request, mediaType: new MediaTypeHeaderValue("application/json")),
|
||||
};
|
||||
var onyxSimpleResponse = await CallOnyxApi<OnyxResponseModel>(onyxSimpleRequest);
|
||||
return (request, onyxSimpleResponse);
|
||||
}
|
||||
|
||||
private async Task<T> CallOnyxApi<T>(HttpRequestMessage request) where T : class, new()
|
||||
{
|
||||
var httpClient = _httpClientFactory.CreateClient("OnyxApi");
|
||||
var response = await httpClient.SendAsync(request);
|
||||
|
||||
if (response.StatusCode != System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
_logger.LogError("Error calling Onyx AI API. Status code: {0}. Response {1}",
|
||||
response.StatusCode, JsonSerializer.Serialize(response));
|
||||
return new T();
|
||||
}
|
||||
var responseStr = await response.Content.ReadAsStringAsync();
|
||||
var responseJson = JsonSerializer.Deserialize<T>(responseStr, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
});
|
||||
|
||||
return responseJson ?? new T();
|
||||
}
|
||||
|
||||
private TAttribute? GetAttribute<TAttribute>(Enum enumValue) where TAttribute : Attribute
|
||||
{
|
||||
var memberInfo = enumValue.GetType().GetMember(enumValue.ToString()).FirstOrDefault();
|
||||
return memberInfo != null ? memberInfo.GetCustomAttribute<TAttribute>() : null;
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Billing.Controllers;
|
||||
|
||||
[Route("freshsales")]
|
||||
public class FreshsalesController : Controller
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ILogger _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
private readonly string _freshsalesApiKey;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public FreshsalesController(IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOptions<BillingSettings> billingSettings,
|
||||
ILogger<FreshsalesController> logger,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri("https://bitwarden.freshsales.io/api/")
|
||||
};
|
||||
|
||||
_freshsalesApiKey = billingSettings.Value.FreshsalesApiKey;
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
||||
"Token",
|
||||
$"token={_freshsalesApiKey}");
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("webhook")]
|
||||
public async Task<IActionResult> PostWebhook([FromHeader(Name = "Authorization")] string key,
|
||||
[FromBody] CustomWebhookRequestModel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || !CoreHelpers.FixedTimeEquals(_freshsalesApiKey, key))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var leadResponse = await _httpClient.GetFromJsonAsync<LeadWrapper<FreshsalesLeadModel>>(
|
||||
$"leads/{request.LeadId}",
|
||||
cancellationToken);
|
||||
|
||||
var lead = leadResponse.Lead;
|
||||
|
||||
var primaryEmail = lead.Emails
|
||||
.Where(e => e.IsPrimary)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (primaryEmail == null)
|
||||
{
|
||||
return BadRequest(new { Message = "Lead has not primary email." });
|
||||
}
|
||||
|
||||
var user = await _userRepository.GetByEmailAsync(primaryEmail.Value);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
var newTags = new HashSet<string>();
|
||||
|
||||
if (user.Premium)
|
||||
{
|
||||
newTags.Add("Premium");
|
||||
}
|
||||
|
||||
var noteItems = new List<string>
|
||||
{
|
||||
$"User, {user.Email}: {_globalSettings.BaseServiceUri.Admin}/users/edit/{user.Id}"
|
||||
};
|
||||
|
||||
var orgs = await _organizationRepository.GetManyByUserIdAsync(user.Id);
|
||||
|
||||
foreach (var org in orgs)
|
||||
{
|
||||
noteItems.Add($"Org, {org.DisplayName()}: {_globalSettings.BaseServiceUri.Admin}/organizations/edit/{org.Id}");
|
||||
if (TryGetPlanName(org.PlanType, out var planName))
|
||||
{
|
||||
newTags.Add($"Org: {planName}");
|
||||
}
|
||||
}
|
||||
|
||||
if (newTags.Any())
|
||||
{
|
||||
var allTags = newTags.Concat(lead.Tags);
|
||||
var updateLeadResponse = await _httpClient.PutAsJsonAsync(
|
||||
$"leads/{request.LeadId}",
|
||||
CreateWrapper(new { tags = allTags }),
|
||||
cancellationToken);
|
||||
updateLeadResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
var createNoteResponse = await _httpClient.PostAsJsonAsync(
|
||||
"notes",
|
||||
CreateNoteRequestModel(request.LeadId, string.Join('\n', noteItems)), cancellationToken);
|
||||
createNoteResponse.EnsureSuccessStatusCode();
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
_logger.LogError(ex, "Error processing freshsales webhook");
|
||||
return BadRequest(new { ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static LeadWrapper<T> CreateWrapper<T>(T lead)
|
||||
{
|
||||
return new LeadWrapper<T>
|
||||
{
|
||||
Lead = lead,
|
||||
};
|
||||
}
|
||||
|
||||
private static CreateNoteRequestModel CreateNoteRequestModel(long leadId, string content)
|
||||
{
|
||||
return new CreateNoteRequestModel
|
||||
{
|
||||
Note = new EditNoteModel
|
||||
{
|
||||
Description = content,
|
||||
TargetableType = "Lead",
|
||||
TargetableId = leadId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryGetPlanName(PlanType planType, out string planName)
|
||||
{
|
||||
switch (planType)
|
||||
{
|
||||
case PlanType.Free:
|
||||
planName = "Free";
|
||||
return true;
|
||||
case PlanType.FamiliesAnnually:
|
||||
case PlanType.FamiliesAnnually2025:
|
||||
case PlanType.FamiliesAnnually2019:
|
||||
planName = "Families";
|
||||
return true;
|
||||
case PlanType.TeamsAnnually:
|
||||
case PlanType.TeamsAnnually2023:
|
||||
case PlanType.TeamsAnnually2020:
|
||||
case PlanType.TeamsAnnually2019:
|
||||
case PlanType.TeamsMonthly:
|
||||
case PlanType.TeamsMonthly2023:
|
||||
case PlanType.TeamsMonthly2020:
|
||||
case PlanType.TeamsMonthly2019:
|
||||
case PlanType.TeamsStarter:
|
||||
case PlanType.TeamsStarter2023:
|
||||
planName = "Teams";
|
||||
return true;
|
||||
case PlanType.EnterpriseAnnually:
|
||||
case PlanType.EnterpriseAnnually2023:
|
||||
case PlanType.EnterpriseAnnually2020:
|
||||
case PlanType.EnterpriseAnnually2019:
|
||||
case PlanType.EnterpriseMonthly:
|
||||
case PlanType.EnterpriseMonthly2023:
|
||||
case PlanType.EnterpriseMonthly2020:
|
||||
case PlanType.EnterpriseMonthly2019:
|
||||
planName = "Enterprise";
|
||||
return true;
|
||||
case PlanType.Custom:
|
||||
planName = "Custom";
|
||||
return true;
|
||||
default:
|
||||
planName = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class CustomWebhookRequestModel
|
||||
{
|
||||
[JsonPropertyName("leadId")]
|
||||
public long LeadId { get; set; }
|
||||
}
|
||||
|
||||
public class LeadWrapper<T>
|
||||
{
|
||||
[JsonPropertyName("lead")]
|
||||
public T Lead { get; set; }
|
||||
|
||||
public static LeadWrapper<TItem> Create<TItem>(TItem lead)
|
||||
{
|
||||
return new LeadWrapper<TItem>
|
||||
{
|
||||
Lead = lead,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class FreshsalesLeadModel
|
||||
{
|
||||
public string[] Tags { get; set; }
|
||||
public FreshsalesEmailModel[] Emails { get; set; }
|
||||
}
|
||||
|
||||
public class FreshsalesEmailModel
|
||||
{
|
||||
[JsonPropertyName("value")]
|
||||
public string Value { get; set; }
|
||||
|
||||
[JsonPropertyName("is_primary")]
|
||||
public bool IsPrimary { get; set; }
|
||||
}
|
||||
|
||||
public class CreateNoteRequestModel
|
||||
{
|
||||
[JsonPropertyName("note")]
|
||||
public EditNoteModel Note { get; set; }
|
||||
}
|
||||
|
||||
public class EditNoteModel
|
||||
{
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
[JsonPropertyName("targetable_type")]
|
||||
public string TargetableType { get; set; }
|
||||
|
||||
[JsonPropertyName("targetable_id")]
|
||||
public long TargetableId { get; set; }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Bit.Billing.Services;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Jobs;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Quartz;
|
||||
using Stripe;
|
||||
@@ -13,12 +14,23 @@ namespace Bit.Billing.Jobs;
|
||||
public class ReconcileAdditionalStorageJob(
|
||||
IStripeFacade stripeFacade,
|
||||
ILogger<ReconcileAdditionalStorageJob> logger,
|
||||
IFeatureService featureService) : BaseJob(logger)
|
||||
IFeatureService featureService,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IStripeEventUtilityService stripeEventUtilityService) : 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;
|
||||
private const short _includedStorageGb = 5;
|
||||
|
||||
public enum SubscriptionPlanTier
|
||||
{
|
||||
Personal,
|
||||
Organization,
|
||||
Unknown
|
||||
}
|
||||
|
||||
protected override async Task ExecuteJobAsync(IJobExecutionContext context)
|
||||
{
|
||||
@@ -34,20 +46,17 @@ public class ReconcileAdditionalStorageJob(
|
||||
var subscriptionsFound = 0;
|
||||
var subscriptionsUpdated = 0;
|
||||
var subscriptionsWithErrors = 0;
|
||||
var databaseUpdatesFailed = 0;
|
||||
var failures = new List<string>();
|
||||
|
||||
logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode);
|
||||
|
||||
var priceIds = new[] { _storageGbMonthlyPriceId, _storageGbAnnuallyPriceId, _personalStorageGbAnnuallyPriceId };
|
||||
var stripeStatusesToProcess = new[] { StripeConstants.SubscriptionStatus.Active, StripeConstants.SubscriptionStatus.Trialing, StripeConstants.SubscriptionStatus.PastDue };
|
||||
|
||||
foreach (var priceId in priceIds)
|
||||
{
|
||||
var options = new SubscriptionListOptions
|
||||
{
|
||||
Limit = 100,
|
||||
Status = StripeConstants.SubscriptionStatus.Active,
|
||||
Price = priceId
|
||||
};
|
||||
var options = new SubscriptionListOptions { Limit = 100, Price = priceId };
|
||||
|
||||
await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options))
|
||||
{
|
||||
@@ -55,16 +64,18 @@ public class ReconcileAdditionalStorageJob(
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " +
|
||||
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
|
||||
"Stripe updates: {StripeUpdates}, Database updates: {DatabaseFailed} failed, " +
|
||||
"Errors: {SubscriptionsWithErrors}{Failures}",
|
||||
subscriptionsFound,
|
||||
liveMode
|
||||
? subscriptionsUpdated
|
||||
: $"(In live mode, would have updated) {subscriptionsUpdated}",
|
||||
databaseUpdatesFailed,
|
||||
subscriptionsWithErrors,
|
||||
failures.Count > 0
|
||||
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
|
||||
: string.Empty
|
||||
);
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,6 +84,12 @@ public class ReconcileAdditionalStorageJob(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!stripeStatusesToProcess.Contains(subscription.Status))
|
||||
{
|
||||
logger.LogInformation("Skipping subscription with unsupported status: {SubscriptionId} - {Status}", subscription.Id, subscription.Status);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id);
|
||||
subscriptionsFound++;
|
||||
|
||||
@@ -97,20 +114,68 @@ public class ReconcileAdditionalStorageJob(
|
||||
|
||||
subscriptionsUpdated++;
|
||||
|
||||
if (!liveMode)
|
||||
// Now, prepare the database update so we can log details out if not in live mode
|
||||
var (organizationId, userId, _) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata ?? new Dictionary<string, string>());
|
||||
var subscriptionPlanTier = DetermineSubscriptionPlanTier(userId, organizationId);
|
||||
|
||||
if (subscriptionPlanTier == SubscriptionPlanTier.Unknown)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}",
|
||||
subscription.Id,
|
||||
Environment.NewLine,
|
||||
JsonSerializer.Serialize(updateOptions));
|
||||
logger.LogError(
|
||||
"Cannot determine subscription plan tier for {SubscriptionId}. Skipping subscription. ",
|
||||
subscription.Id);
|
||||
subscriptionsWithErrors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var entityId =
|
||||
subscriptionPlanTier switch
|
||||
{
|
||||
SubscriptionPlanTier.Personal => userId!.Value,
|
||||
SubscriptionPlanTier.Organization => organizationId!.Value,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(subscriptionPlanTier), subscriptionPlanTier, null)
|
||||
};
|
||||
|
||||
// Calculate new MaxStorageGb
|
||||
var currentStorageQuantity = GetCurrentStorageQuantityFromSubscription(subscription, priceId);
|
||||
var newMaxStorageGb = CalculateNewMaxStorageGb(currentStorageQuantity, updateOptions);
|
||||
|
||||
if (!liveMode)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}" +
|
||||
"{NewLine2}And would have updated database record tier: {Tier} to new MaxStorageGb: {MaxStorageGb}",
|
||||
subscription.Id,
|
||||
Environment.NewLine,
|
||||
JsonSerializer.Serialize(updateOptions),
|
||||
Environment.NewLine,
|
||||
subscriptionPlanTier,
|
||||
newMaxStorageGb);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Live mode enabled - continue with updates to stripe and database
|
||||
try
|
||||
{
|
||||
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
|
||||
logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id);
|
||||
logger.LogInformation("Successfully updated Stripe subscription: {SubscriptionId}", subscription.Id);
|
||||
|
||||
logger.LogInformation(
|
||||
"Updating MaxStorageGb in database for subscription {SubscriptionId} ({Type}): New MaxStorageGb: {MaxStorage}",
|
||||
subscription.Id,
|
||||
subscriptionPlanTier,
|
||||
newMaxStorageGb);
|
||||
|
||||
var dbUpdateSuccess = await UpdateDatabaseMaxStorageAsync(
|
||||
subscriptionPlanTier,
|
||||
entityId,
|
||||
newMaxStorageGb,
|
||||
subscription.Id);
|
||||
|
||||
if (!dbUpdateSuccess)
|
||||
{
|
||||
databaseUpdatesFailed++;
|
||||
failures.Add($"Subscription {subscription.Id}: Database update failed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -123,17 +188,19 @@ public class ReconcileAdditionalStorageJob(
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " +
|
||||
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
|
||||
"ReconcileAdditionalStorageJob FINISHED. Subscriptions found: {SubscriptionsFound}, " +
|
||||
"Subscriptions updated: {SubscriptionsUpdated}, Database failures: {DatabaseFailed}, " +
|
||||
"Total Subscriptions With Errors: {SubscriptionsWithErrors}{Failures}",
|
||||
subscriptionsFound,
|
||||
liveMode
|
||||
? subscriptionsUpdated
|
||||
: $"(In live mode, would have updated) {subscriptionsUpdated}",
|
||||
databaseUpdatesFailed,
|
||||
subscriptionsWithErrors,
|
||||
failures.Count > 0
|
||||
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
|
||||
: string.Empty
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
private SubscriptionUpdateOptions? BuildSubscriptionUpdateOptions(
|
||||
@@ -145,15 +212,7 @@ public class ReconcileAdditionalStorageJob(
|
||||
return null;
|
||||
}
|
||||
|
||||
var updateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o")
|
||||
},
|
||||
Items = []
|
||||
};
|
||||
var updateOptions = new SubscriptionUpdateOptions { ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, Metadata = new Dictionary<string, string> { [StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o") }, Items = [] };
|
||||
|
||||
var hasUpdates = false;
|
||||
|
||||
@@ -172,11 +231,7 @@ public class ReconcileAdditionalStorageJob(
|
||||
newQuantity,
|
||||
item.Price.Id);
|
||||
|
||||
updateOptions.Items.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = item.Id,
|
||||
Quantity = newQuantity
|
||||
});
|
||||
updateOptions.Items.Add(new SubscriptionItemOptions { Id = item.Id, Quantity = newQuantity });
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -185,17 +240,124 @@ public class ReconcileAdditionalStorageJob(
|
||||
currentQuantity,
|
||||
item.Price.Id);
|
||||
|
||||
updateOptions.Items.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = item.Id,
|
||||
Deleted = true
|
||||
});
|
||||
updateOptions.Items.Add(new SubscriptionItemOptions { Id = item.Id, Deleted = true });
|
||||
}
|
||||
}
|
||||
|
||||
return hasUpdates ? updateOptions : null;
|
||||
}
|
||||
|
||||
public SubscriptionPlanTier DetermineSubscriptionPlanTier(
|
||||
Guid? userId,
|
||||
Guid? organizationId)
|
||||
{
|
||||
return userId.HasValue
|
||||
? SubscriptionPlanTier.Personal
|
||||
: organizationId.HasValue
|
||||
? SubscriptionPlanTier.Organization
|
||||
: SubscriptionPlanTier.Unknown;
|
||||
}
|
||||
|
||||
public long GetCurrentStorageQuantityFromSubscription(
|
||||
Subscription subscription,
|
||||
string storagePriceId)
|
||||
{
|
||||
return subscription.Items?.Data?.FirstOrDefault(item => item?.Price?.Id == storagePriceId)?.Quantity ?? 0;
|
||||
}
|
||||
|
||||
public short CalculateNewMaxStorageGb(
|
||||
long currentQuantity,
|
||||
SubscriptionUpdateOptions? updateOptions)
|
||||
{
|
||||
if (updateOptions?.Items == null)
|
||||
{
|
||||
return (short)(_includedStorageGb + currentQuantity);
|
||||
}
|
||||
|
||||
// If the update marks item as deleted, new quantity is whatever the base storage gb
|
||||
if (updateOptions.Items.Any(i => i.Deleted == true))
|
||||
{
|
||||
return _includedStorageGb;
|
||||
}
|
||||
|
||||
// If the update has a new quantity, use it to calculate the new max
|
||||
var updatedItem = updateOptions.Items.FirstOrDefault(i => i.Quantity.HasValue);
|
||||
if (updatedItem?.Quantity != null)
|
||||
{
|
||||
return (short)(_includedStorageGb + updatedItem.Quantity.Value);
|
||||
}
|
||||
|
||||
// Otherwise, no change
|
||||
return (short)(_includedStorageGb + currentQuantity);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateDatabaseMaxStorageAsync(
|
||||
SubscriptionPlanTier subscriptionPlanTier,
|
||||
Guid entityId,
|
||||
short newMaxStorageGb,
|
||||
string subscriptionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (subscriptionPlanTier)
|
||||
{
|
||||
case SubscriptionPlanTier.Personal:
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(entityId);
|
||||
if (user == null)
|
||||
{
|
||||
logger.LogError(
|
||||
"User not found for subscription {SubscriptionId}. Database not updated.",
|
||||
subscriptionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
user.MaxStorageGb = newMaxStorageGb;
|
||||
await userRepository.ReplaceAsync(user);
|
||||
|
||||
logger.LogInformation(
|
||||
"Successfully updated User {UserId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}",
|
||||
user.Id,
|
||||
newMaxStorageGb,
|
||||
subscriptionId);
|
||||
return true;
|
||||
}
|
||||
case SubscriptionPlanTier.Organization:
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(entityId);
|
||||
if (organization == null)
|
||||
{
|
||||
logger.LogError(
|
||||
"Organization not found for subscription {SubscriptionId}. Database not updated.",
|
||||
subscriptionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
organization.MaxStorageGb = newMaxStorageGb;
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
logger.LogInformation(
|
||||
"Successfully updated Organization {OrganizationId} MaxStorageGb to {MaxStorageGb} for subscription {SubscriptionId}",
|
||||
organization.Id,
|
||||
newMaxStorageGb,
|
||||
subscriptionId);
|
||||
return true;
|
||||
}
|
||||
case SubscriptionPlanTier.Unknown:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"Failed to update database MaxStorageGb for subscription {SubscriptionId} (Plan Tier: {SubscriptionType})",
|
||||
subscriptionId,
|
||||
subscriptionPlanTier);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static ITrigger GetTrigger()
|
||||
{
|
||||
return TriggerBuilder.Create()
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class FreshdeskReplyRequestModel
|
||||
{
|
||||
[JsonPropertyName("body")]
|
||||
public required string Body { get; set; }
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class FreshdeskWebhookModel
|
||||
{
|
||||
[JsonPropertyName("ticket_id")]
|
||||
public string TicketId { get; set; }
|
||||
|
||||
[JsonPropertyName("ticket_contact_email")]
|
||||
public string TicketContactEmail { get; set; }
|
||||
|
||||
[JsonPropertyName("ticket_tags")]
|
||||
public string TicketTags { get; set; }
|
||||
}
|
||||
|
||||
public class FreshdeskOnyxAiWebhookModel : FreshdeskWebhookModel
|
||||
{
|
||||
[JsonPropertyName("ticket_description_text")]
|
||||
public string TicketDescriptionText { get; set; }
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using static Bit.Billing.BillingSettings;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class OnyxRequestModel
|
||||
{
|
||||
[JsonPropertyName("persona_id")]
|
||||
public int PersonaId { get; set; } = 1;
|
||||
|
||||
[JsonPropertyName("retrieval_options")]
|
||||
public RetrievalOptions RetrievalOptions { get; set; } = new RetrievalOptions();
|
||||
|
||||
public OnyxRequestModel(OnyxSettings onyxSettings)
|
||||
{
|
||||
PersonaId = onyxSettings.PersonaId;
|
||||
RetrievalOptions.RunSearch = onyxSettings.SearchSettings.RunSearch;
|
||||
RetrievalOptions.RealTime = onyxSettings.SearchSettings.RealTime;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is used with the onyx endpoint /query/answer-with-citation
|
||||
/// which has been deprecated. This can be removed once later
|
||||
/// </summary>
|
||||
public class OnyxAnswerWithCitationRequestModel : OnyxRequestModel
|
||||
{
|
||||
[JsonPropertyName("messages")]
|
||||
public List<Message> Messages { get; set; } = new List<Message>();
|
||||
|
||||
public OnyxAnswerWithCitationRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
|
||||
{
|
||||
message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
|
||||
Messages = new List<Message>() { new Message() { MessageText = message } };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is used with the onyx endpoint /chat/send-message-simple-api
|
||||
/// </summary>
|
||||
public class OnyxSendMessageSimpleApiRequestModel : OnyxRequestModel
|
||||
{
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public OnyxSendMessageSimpleApiRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
|
||||
{
|
||||
Message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
public class Message
|
||||
{
|
||||
[JsonPropertyName("message")]
|
||||
public string MessageText { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sender")]
|
||||
public string Sender { get; set; } = "user";
|
||||
}
|
||||
|
||||
public class RetrievalOptions
|
||||
{
|
||||
[JsonPropertyName("run_search")]
|
||||
public string RunSearch { get; set; } = RetrievalOptionsRunSearch.Auto;
|
||||
|
||||
[JsonPropertyName("real_time")]
|
||||
public bool RealTime { get; set; } = true;
|
||||
}
|
||||
|
||||
public class RetrievalOptionsRunSearch
|
||||
{
|
||||
public const string Always = "always";
|
||||
public const string Never = "never";
|
||||
public const string Auto = "auto";
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Billing.Models;
|
||||
|
||||
public class OnyxResponseModel
|
||||
{
|
||||
[JsonPropertyName("answer")]
|
||||
public string Answer { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("answer_citationless")]
|
||||
public string AnswerCitationless { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("error_msg")]
|
||||
public string ErrorMsg { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public interface IStripeEventUtilityService
|
||||
/// <param name="userId"></param>
|
||||
/// /// <param name="providerId"></param>
|
||||
/// <returns></returns>
|
||||
Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId);
|
||||
Task<Transaction> FromChargeToTransactionAsync(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to pay the specified invoice. If a customer is eligible, the invoice is paid using Braintree or Stripe.
|
||||
|
||||
@@ -20,6 +20,12 @@ public interface IStripeFacade
|
||||
RequestOptions requestOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
IAsyncEnumerable<CustomerCashBalanceTransaction> GetCustomerCashBalanceTransactions(
|
||||
string customerId,
|
||||
CustomerCashBalanceTransactionListOptions customerCashBalanceTransactionListOptions = null,
|
||||
RequestOptions requestOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Customer> UpdateCustomer(
|
||||
string customerId,
|
||||
CustomerUpdateOptions customerUpdateOptions = null,
|
||||
|
||||
@@ -38,7 +38,7 @@ public class ChargeRefundedHandler : IChargeRefundedHandler
|
||||
{
|
||||
// Attempt to create a transaction for the charge if it doesn't exist
|
||||
var (organizationId, userId, providerId) = await _stripeEventUtilityService.GetEntityIdsFromChargeAsync(charge);
|
||||
var tx = _stripeEventUtilityService.FromChargeToTransaction(charge, organizationId, userId, providerId);
|
||||
var tx = await _stripeEventUtilityService.FromChargeToTransactionAsync(charge, organizationId, userId, providerId);
|
||||
try
|
||||
{
|
||||
parentTransaction = await _transactionRepository.CreateAsync(tx);
|
||||
|
||||
@@ -46,7 +46,7 @@ public class ChargeSucceededHandler : IChargeSucceededHandler
|
||||
return;
|
||||
}
|
||||
|
||||
var transaction = _stripeEventUtilityService.FromChargeToTransaction(charge, organizationId, userId, providerId);
|
||||
var transaction = await _stripeEventUtilityService.FromChargeToTransactionAsync(charge, organizationId, userId, providerId);
|
||||
if (!transaction.PaymentMethodType.HasValue)
|
||||
{
|
||||
_logger.LogWarning("Charge success from unsupported source/method. {ChargeId}", charge.Id);
|
||||
|
||||
@@ -124,7 +124,7 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
||||
/// <param name="userId"></param>
|
||||
/// /// <param name="providerId"></param>
|
||||
/// <returns></returns>
|
||||
public Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId)
|
||||
public async Task<Transaction> FromChargeToTransactionAsync(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId)
|
||||
{
|
||||
var transaction = new Transaction
|
||||
{
|
||||
@@ -209,6 +209,24 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
||||
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
|
||||
transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}";
|
||||
}
|
||||
else if (charge.PaymentMethodDetails.CustomerBalance != null)
|
||||
{
|
||||
var bankTransferType = await GetFundingBankTransferTypeAsync(charge);
|
||||
|
||||
if (!string.IsNullOrEmpty(bankTransferType))
|
||||
{
|
||||
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
|
||||
transaction.Details = bankTransferType switch
|
||||
{
|
||||
"eu_bank_transfer" => "EU Bank Transfer",
|
||||
"gb_bank_transfer" => "GB Bank Transfer",
|
||||
"jp_bank_transfer" => "JP Bank Transfer",
|
||||
"mx_bank_transfer" => "MX Bank Transfer",
|
||||
"us_bank_transfer" => "US Bank Transfer",
|
||||
_ => "Bank Transfer"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -406,4 +424,55 @@ public class StripeEventUtilityService : IStripeEventUtilityService
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the bank transfer type that funded a charge paid via customer balance.
|
||||
/// </summary>
|
||||
/// <param name="charge">The charge to analyze.</param>
|
||||
/// <returns>
|
||||
/// The bank transfer type (e.g., "us_bank_transfer", "eu_bank_transfer") if the charge was funded
|
||||
/// by a bank transfer via customer balance, otherwise null.
|
||||
/// </returns>
|
||||
private async Task<string> GetFundingBankTransferTypeAsync(Charge charge)
|
||||
{
|
||||
if (charge is not
|
||||
{
|
||||
CustomerId: not null,
|
||||
PaymentIntentId: not null,
|
||||
PaymentMethodDetails: { Type: "customer_balance" }
|
||||
})
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cashBalanceTransactions = _stripeFacade.GetCustomerCashBalanceTransactions(charge.CustomerId);
|
||||
|
||||
string bankTransferType = null;
|
||||
var matchingPaymentIntentFound = false;
|
||||
|
||||
await foreach (var cashBalanceTransaction in cashBalanceTransactions)
|
||||
{
|
||||
switch (cashBalanceTransaction)
|
||||
{
|
||||
case { Type: "funded", Funded: not null }:
|
||||
{
|
||||
bankTransferType = cashBalanceTransaction.Funded.BankTransfer.Type;
|
||||
break;
|
||||
}
|
||||
case { Type: "applied_to_payment", AppliedToPayment: not null }
|
||||
when cashBalanceTransaction.AppliedToPayment.PaymentIntentId == charge.PaymentIntentId:
|
||||
{
|
||||
matchingPaymentIntentFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingPaymentIntentFound && !string.IsNullOrEmpty(bankTransferType))
|
||||
{
|
||||
return bankTransferType;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public class StripeFacade : IStripeFacade
|
||||
{
|
||||
private readonly ChargeService _chargeService = new();
|
||||
private readonly CustomerService _customerService = new();
|
||||
private readonly CustomerCashBalanceTransactionService _customerCashBalanceTransactionService = new();
|
||||
private readonly EventService _eventService = new();
|
||||
private readonly InvoiceService _invoiceService = new();
|
||||
private readonly PaymentMethodService _paymentMethodService = new();
|
||||
@@ -41,6 +42,13 @@ public class StripeFacade : IStripeFacade
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<CustomerCashBalanceTransaction> GetCustomerCashBalanceTransactions(
|
||||
string customerId,
|
||||
CustomerCashBalanceTransactionListOptions customerCashBalanceTransactionListOptions = null,
|
||||
RequestOptions requestOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _customerCashBalanceTransactionService.ListAutoPagingAsync(customerId, customerCashBalanceTransactionListOptions, requestOptions, cancellationToken);
|
||||
|
||||
public async Task<Customer> UpdateCustomer(
|
||||
string customerId,
|
||||
CustomerUpdateOptions customerUpdateOptions = null,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
#nullable disable
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Billing.Services.Implementations;
|
||||
using Bit.Commercial.Core.Utilities;
|
||||
@@ -98,13 +97,6 @@ public class Startup
|
||||
// Authentication
|
||||
services.AddAuthentication();
|
||||
|
||||
// Set up HttpClients
|
||||
services.AddHttpClient("FreshdeskApi");
|
||||
services.AddHttpClient("OnyxApi", client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", billingSettings.Onyx.ApiKey);
|
||||
});
|
||||
|
||||
services.AddScoped<IStripeFacade, StripeFacade>();
|
||||
services.AddScoped<IStripeEventService, StripeEventService>();
|
||||
services.AddScoped<IProviderEventService, ProviderEventService>();
|
||||
|
||||
@@ -32,10 +32,5 @@
|
||||
"connectionString": "UseDevelopmentStorage=true"
|
||||
}
|
||||
},
|
||||
"billingSettings": {
|
||||
"onyx": {
|
||||
"personaId": 68
|
||||
}
|
||||
},
|
||||
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||
}
|
||||
|
||||
@@ -26,10 +26,7 @@
|
||||
"payPal": {
|
||||
"production": true,
|
||||
"businessId": "4ZDA7DLUUJGMN"
|
||||
},
|
||||
"onyx": {
|
||||
"personaId": 7
|
||||
}
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"IncludeScopes": false,
|
||||
|
||||
@@ -61,27 +61,6 @@
|
||||
"production": false,
|
||||
"businessId": "AD3LAUZSNVPJY",
|
||||
"webhookKey": "SECRET"
|
||||
},
|
||||
"freshdesk": {
|
||||
"apiKey": "SECRET",
|
||||
"webhookKey": "SECRET",
|
||||
"region": "US",
|
||||
"userFieldName": "cf_user",
|
||||
"orgFieldName": "cf_org",
|
||||
"removeNewlinesInReplies": true,
|
||||
"autoReplyGreeting": "<b>Greetings,</b><br /><br />Thank you for contacting Bitwarden. The reply below was generated by our AI agent based on your message:<br /><br />",
|
||||
"autoReplySalutation": "<br /><br />If this response doesn’t fully address your question, simply reply to this email and a member of our Customer Success team will be happy to assist you further.<br /><p><b>Best Regards,</b><br />The Bitwarden Customer Success Team</p>"
|
||||
},
|
||||
"onyx": {
|
||||
"apiKey": "SECRET",
|
||||
"baseUrl": "https://cloud.onyx.app/api",
|
||||
"path": "/chat/send-message-simple-api",
|
||||
"useAnswerWithCitationModels": true,
|
||||
"personaId": 7,
|
||||
"searchSettings": {
|
||||
"runSearch": "always",
|
||||
"realTime": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
|
||||
/// </summary>
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If set to true, disables Secrets Manager ads for users in the organization
|
||||
/// </summary>
|
||||
public bool UseDisableSmAdsForUsers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If set to true, the organization has phishing protection enabled.
|
||||
/// </summary>
|
||||
@@ -338,6 +343,7 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
|
||||
UseRiskInsights = license.UseRiskInsights;
|
||||
UseOrganizationDomains = license.UseOrganizationDomains;
|
||||
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
|
||||
UseDisableSmAdsForUsers = license.UseDisableSmAdsForUsers;
|
||||
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation;
|
||||
UsePhishingBlocker = license.UsePhishingBlocker;
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record DatadogIntegration(string ApiKey, Uri Uri);
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationHandlerResult
|
||||
{
|
||||
public IntegrationHandlerResult(bool success, IIntegrationMessage message)
|
||||
{
|
||||
Success = success;
|
||||
Message = message;
|
||||
}
|
||||
|
||||
public bool Success { get; set; } = false;
|
||||
public bool Retryable { get; set; } = false;
|
||||
public IIntegrationMessage Message { get; set; }
|
||||
public DateTime? DelayUntilDate { get; set; }
|
||||
public string FailureReason { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegration(string Token);
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record SlackIntegrationConfiguration(string ChannelId);
|
||||
@@ -53,5 +53,7 @@ public interface IProfileOrganizationDetails
|
||||
bool UseAdminSponsoredFamilies { get; set; }
|
||||
bool UseOrganizationDomains { get; set; }
|
||||
bool UseAutomaticUserConfirmation { get; set; }
|
||||
bool UseDisableSMAdsForUsers { get; set; }
|
||||
|
||||
bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
|
||||
@@ -65,5 +65,6 @@ public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool? IsAdminInitiated { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
public bool UseDisableSMAdsForUsers { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ public class SelfHostedOrganizationDetails : Organization
|
||||
Status = Status,
|
||||
UseRiskInsights = UseRiskInsights,
|
||||
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
|
||||
UseDisableSmAdsForUsers = UseDisableSmAdsForUsers,
|
||||
UsePhishingBlocker = UsePhishingBlocker,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,5 +56,6 @@ public class ProviderUserOrganizationDetails : IProfileOrganizationDetails
|
||||
public string? SsoExternalId { get; set; }
|
||||
public string? Permissions { get; set; }
|
||||
public string? ResetPasswordKey { get; set; }
|
||||
public bool UseDisableSMAdsForUsers { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation;
|
||||
|
||||
public abstract class OrganizationConfirmationBaseView : BaseMailView
|
||||
{
|
||||
public required string OrganizationName { get; set; }
|
||||
public required string TitleFirst { get; set; }
|
||||
public required string TitleSecondBold { get; set; }
|
||||
public required string TitleThird { get; set; }
|
||||
public required string WebVaultUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation;
|
||||
|
||||
public class OrganizationConfirmationEnterpriseTeamsView : OrganizationConfirmationBaseView
|
||||
{
|
||||
}
|
||||
|
||||
public class OrganizationConfirmationEnterpriseTeams : BaseMail<OrganizationConfirmationEnterpriseTeamsView>
|
||||
{
|
||||
public override required string Subject { get; set; }
|
||||
}
|
||||
@@ -178,7 +178,7 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ffffff" role="presentation" style="border:none;border-radius:20px;cursor:auto;mso-padding-alt:10px 25px;background:#ffffff;" valign="middle">
|
||||
<a href="https://vault.bitwarden.com" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
|
||||
<a href="{{{WebVaultUrl}}}" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
|
||||
Log in
|
||||
</a>
|
||||
</td>
|
||||
@@ -502,12 +502,12 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#F3F6F9" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
|
||||
<div style="background:#F3F6F9;background-color:#F3F6F9;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#F3F6F9;background-color:#F3F6F9;width:100%;border-radius:0px 0px 4px 4px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
|
||||
@@ -595,8 +595,8 @@
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
@@ -612,13 +612,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://x.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-x-twitter.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -635,13 +635,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-reddit.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -658,13 +658,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://community.bitwarden.com/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-discourse.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -681,13 +681,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://github.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-github.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -704,13 +704,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-youtube.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -727,13 +727,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-linkedin.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -750,13 +750,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.facebook.com/bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-facebook.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -777,15 +777,15 @@
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px; margin-top: 5px">
|
||||
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
Barbara, CA, USA
|
||||
</p>
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
@@ -0,0 +1,12 @@
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation;
|
||||
|
||||
public class OrganizationConfirmationFamilyFreeView : OrganizationConfirmationBaseView
|
||||
{
|
||||
}
|
||||
|
||||
public class OrganizationConfirmationFamilyFree : BaseMail<OrganizationConfirmationFamilyFreeView>
|
||||
{
|
||||
public override required string Subject { get; set; }
|
||||
}
|
||||
@@ -182,7 +182,7 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ffffff" role="presentation" style="border:none;border-radius:20px;cursor:auto;mso-padding-alt:10px 25px;background:#ffffff;" valign="middle">
|
||||
<a href="https://vault.bitwarden.com" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
|
||||
<a href="{{{WebVaultUrl}}}" style="display:inline-block;background:#ffffff;color:#1A41AC;font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:20px;" target="_blank">
|
||||
Log in
|
||||
</a>
|
||||
</td>
|
||||
@@ -670,12 +670,12 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#F3F6F9" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
|
||||
|
||||
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
|
||||
<div style="background:#F3F6F9;background-color:#F3F6F9;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#F3F6F9;background-color:#F3F6F9;width:100%;border-radius:0px 0px 4px 4px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
|
||||
@@ -763,8 +763,8 @@
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
|
||||
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
@@ -780,13 +780,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://x.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-x-twitter.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -803,13 +803,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-reddit.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -826,13 +826,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://community.bitwarden.com/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-discourse.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -849,13 +849,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://github.com/bitwarden" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-github.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -872,13 +872,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-youtube.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -895,13 +895,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-linkedin.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -918,13 +918,13 @@
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="padding:10px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
|
||||
<td style="padding:8px;vertical-align:middle;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
|
||||
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
|
||||
<a href="https://www.facebook.com/bitwarden/" target="_blank">
|
||||
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
|
||||
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-facebook.png" style="border-radius:3px;display:block;" width="24">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -945,15 +945,15 @@
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
|
||||
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px; margin-top: 5px">
|
||||
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
|
||||
Barbara, CA, USA
|
||||
</p>
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
@@ -29,6 +29,7 @@ public class OrganizationAbility
|
||||
UseOrganizationDomains = organization.UseOrganizationDomains;
|
||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
||||
UseDisableSmAdsForUsers = organization.UseDisableSmAdsForUsers;
|
||||
UsePhishingBlocker = organization.UsePhishingBlocker;
|
||||
}
|
||||
|
||||
@@ -52,5 +53,6 @@ public class OrganizationAbility
|
||||
public bool UseOrganizationDomains { get; set; }
|
||||
public bool UseAdminSponsoredFamilies { get; set; }
|
||||
public bool UseAutomaticUserConfirmation { get; set; }
|
||||
public bool UseDisableSmAdsForUsers { get; set; }
|
||||
public bool UsePhishingBlocker { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
# Organization Ability Flags
|
||||
|
||||
## Overview
|
||||
|
||||
Many Bitwarden features are tied to specific subscription plans. For example, SCIM and SSO are Enterprise features,
|
||||
while Event Logs are available to Teams and Enterprise plans. When developing features that require plan-based access
|
||||
control, we use **Organization Ability Flags** (or simply _abilities_) — explicit boolean properties on the Organization
|
||||
entity that indicate whether an organization can use a specific feature.
|
||||
|
||||
## The Rule
|
||||
|
||||
**Never check plan types to control feature access.** Always use a dedicated ability flag on the Organization entity.
|
||||
|
||||
### ❌ Don't Do This
|
||||
|
||||
```csharp
|
||||
// Checking plan type directly
|
||||
if (organization.PlanType == PlanType.Enterprise ||
|
||||
organization.PlanType == PlanType.Teams ||
|
||||
organization.PlanType == PlanType.Family)
|
||||
{
|
||||
// allow feature...
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Don't Do This
|
||||
|
||||
```csharp
|
||||
// Piggybacking off another feature's ability
|
||||
if (organization.PlanType == PlanType.Enterprise && organization.UseEvents)
|
||||
{
|
||||
// assume they can use some other feature...
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Do This Instead
|
||||
|
||||
```csharp
|
||||
// Check the explicit ability flag
|
||||
if (organization.UseEvents)
|
||||
{
|
||||
// allow UseEvents feature...
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Pattern Matters
|
||||
|
||||
Using explicit ability flags instead of plan type checks provides several benefits:
|
||||
|
||||
1. **Simplicity** — A single boolean check is cleaner and less error-prone than maintaining lists of plan types.
|
||||
|
||||
2. **Centralized Control** — Feature access is managed in one place: the ability assignment during organization
|
||||
creation/upgrade. No need to hunt through the codebase for scattered plan type checks.
|
||||
|
||||
3. **Flexibility** — Abilities can be set independently of plan type, enabling:
|
||||
|
||||
- Early access programs for features not yet tied to a plan
|
||||
- Trial access to help customers evaluate a feature before upgrading
|
||||
- Custom arrangements for specific customers
|
||||
- A/B testing of features across different cohorts
|
||||
|
||||
4. **Safe Refactoring** — When plans change (e.g., adding a new plan tier, renaming plans, or moving features between
|
||||
tiers), we only update the ability assignment logic—not every place the feature is used.
|
||||
|
||||
5. **Graceful Downgrades** — When an organization downgrades, we update their abilities. All feature checks
|
||||
automatically respect the new access level.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Ability Assignment at Signup/Upgrade
|
||||
|
||||
When an organization is created or changes plans, the ability flags are set based on the plan's capabilities:
|
||||
|
||||
```csharp
|
||||
// During organization creation or plan change
|
||||
organization.UseGroups = plan.HasGroups;
|
||||
organization.UseSso = plan.HasSso;
|
||||
organization.UseScim = plan.HasScim;
|
||||
organization.UsePolicies = plan.HasPolicies;
|
||||
organization.UseEvents = plan.HasEvents;
|
||||
// ... etc
|
||||
```
|
||||
|
||||
### Modifying Abilities for Existing Organizations
|
||||
|
||||
To change abilities for existing organizations (e.g., rolling out a feature to a new plan tier), create a database
|
||||
migration that updates the relevant flag:
|
||||
|
||||
```sql
|
||||
-- Example: Enable UseEvents for all Teams organizations
|
||||
UPDATE [dbo].[Organization]
|
||||
SET UseEvents = 1
|
||||
WHERE PlanType IN (17, 18) -- TeamsMonthly = 17, TeamsAnnually = 18
|
||||
```
|
||||
|
||||
Then update the plan-to-ability assignment code so new organizations get the correct value.
|
||||
|
||||
## Adding a New Ability
|
||||
|
||||
When developing a new plan-gated feature:
|
||||
|
||||
1. **Add the ability to the Organization and OrganizationAbility entities** — Create a `Use[FeatureName]` boolean
|
||||
property.
|
||||
|
||||
2. **Add a database migration** — Add the new column to the Organization table.
|
||||
|
||||
3. **Update plan definitions** — Add a corresponding `Has[FeatureName]` property to the Plan model and configure which
|
||||
plans include it.
|
||||
|
||||
4. **Update organization creation/upgrade logic** — Ensure the ability is set based on the plan.
|
||||
|
||||
5. **Update the organization license claims** (if applicable) - to make the feature available on self-hosted instances.
|
||||
|
||||
6. **Implement checks throughout client and server** — Use the ability consistently everywhere the feature is accessed.
|
||||
- Clients: get the organization object from `OrganizationService`.
|
||||
- Server: if you already have the full `Organization` object in scope, you can use it directly. If not, use the
|
||||
`IApplicationCacheService` to retrieve the `OrganizationAbility`, which is a simplified, cached representation
|
||||
of the organization ability flags. Note that some older flags may be missing from `OrganizationAbility` but
|
||||
can be added if needed.
|
||||
|
||||
## Existing Abilities
|
||||
|
||||
For reference, here are some current organization ability flags (not a complete list):
|
||||
|
||||
| Ability | Description | Plans |
|
||||
|--------------------------|-------------------------------|-------------------|
|
||||
| `UseGroups` | Group-based collection access | Teams, Enterprise |
|
||||
| `UseDirectory` | Directory Connector sync | Teams, Enterprise |
|
||||
| `UseEvents` | Event logging | Teams, Enterprise |
|
||||
| `UseTotp` | Authenticator (TOTP) | Teams, Enterprise |
|
||||
| `UseSso` | Single Sign-On | Enterprise |
|
||||
| `UseScim` | SCIM provisioning | Teams, Enterprise |
|
||||
| `UsePolicies` | Enterprise policies | Enterprise |
|
||||
| `UseResetPassword` | Admin password reset | Enterprise |
|
||||
| `UseOrganizationDomains` | Domain verification/claiming | Enterprise |
|
||||
|
||||
## Questions?
|
||||
|
||||
If you're unsure whether your feature needs a new ability or which existing ability to use, reach out to your team lead
|
||||
or members of the Admin Console or Architecture teams. When in doubt, adding an explicit ability is almost always the
|
||||
right choice—it's easy to do and keeps our access control clean and maintainable.
|
||||
@@ -270,7 +270,9 @@ public class AcceptOrgUserCommand : IAcceptOrgUserCommand
|
||||
ICollection<OrganizationUser> allOrgUsers, User user)
|
||||
{
|
||||
var error = (await _automaticUserConfirmationPolicyEnforcementValidator.IsCompliantAsync(
|
||||
new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId, allOrgUsers, user)))
|
||||
new AutomaticUserConfirmationPolicyEnforcementRequest(orgUser.OrganizationId,
|
||||
allOrgUsers.Append(orgUser),
|
||||
user)))
|
||||
.Match(
|
||||
error => error.Message,
|
||||
_ => string.Empty
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.Entities;
|
||||
@@ -25,6 +27,8 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
|
||||
IPushNotificationService pushNotificationService,
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
ICollectionRepository collectionRepository,
|
||||
IFeatureService featureService,
|
||||
ISendOrganizationConfirmationCommand sendOrganizationConfirmationCommand,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AutomaticallyConfirmOrganizationUserCommand> logger) : IAutomaticallyConfirmOrganizationUserCommand
|
||||
{
|
||||
@@ -143,9 +147,7 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(request.OrganizationUser!.UserId!.Value);
|
||||
|
||||
await mailService.SendOrganizationConfirmedEmailAsync(request.Organization!.Name,
|
||||
user!.Email,
|
||||
request.OrganizationUser.AccessSecretsManager);
|
||||
await SendOrganizationConfirmedEmailAsync(request.Organization!, user!.Email, request.OrganizationUser.AccessSecretsManager);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -183,4 +185,23 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
|
||||
Organization = await organizationRepository.GetByIdAsync(request.OrganizationId)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service,
|
||||
/// depending on the feature flag.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization the user was confirmed to.</param>
|
||||
/// <param name="userEmail">The email address of the confirmed user.</param>
|
||||
/// <param name="accessSecretsManager">Whether the user has access to Secrets Manager.</param>
|
||||
internal async Task SendOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager)
|
||||
{
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail))
|
||||
{
|
||||
await sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager);
|
||||
}
|
||||
else
|
||||
{
|
||||
await mailService.SendOrganizationConfirmedEmailAsync(organization.Name, userEmail, accessSecretsManager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +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.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
@@ -35,7 +37,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IAutomaticUserConfirmationPolicyEnforcementValidator _automaticUserConfirmationPolicyEnforcementValidator;
|
||||
|
||||
private readonly ISendOrganizationConfirmationCommand _sendOrganizationConfirmationCommand;
|
||||
public ConfirmOrganizationUserCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
@@ -50,7 +52,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IFeatureService featureService,
|
||||
ICollectionRepository collectionRepository,
|
||||
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator)
|
||||
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator, ISendOrganizationConfirmationCommand sendOrganizationConfirmationCommand)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@@ -66,8 +68,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
_featureService = featureService;
|
||||
_collectionRepository = collectionRepository;
|
||||
_automaticUserConfirmationPolicyEnforcementValidator = automaticUserConfirmationPolicyEnforcementValidator;
|
||||
_sendOrganizationConfirmationCommand = sendOrganizationConfirmationCommand;
|
||||
}
|
||||
|
||||
public async Task<OrganizationUser> ConfirmUserAsync(Guid organizationId, Guid organizationUserId, string key,
|
||||
Guid confirmingUserId, string defaultUserCollectionName = null)
|
||||
{
|
||||
@@ -170,7 +172,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
orgUser.Email = null;
|
||||
|
||||
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_Confirmed);
|
||||
await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), user.Email, orgUser.AccessSecretsManager);
|
||||
await SendOrganizationConfirmedEmailAsync(organization, user.Email, orgUser.AccessSecretsManager);
|
||||
succeededUsers.Add(orgUser);
|
||||
result.Add(Tuple.Create(orgUser, ""));
|
||||
}
|
||||
@@ -280,11 +282,6 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
/// <param name="defaultUserCollectionName">The encrypted default user collection name.</param>
|
||||
private async Task CreateDefaultCollectionAsync(OrganizationUser organizationUser, string defaultUserCollectionName)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if no collection name provided (backwards compatibility)
|
||||
if (string.IsNullOrWhiteSpace(defaultUserCollectionName))
|
||||
{
|
||||
@@ -323,11 +320,6 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
private async Task CreateManyDefaultCollectionsAsync(Guid organizationId,
|
||||
IEnumerable<OrganizationUser> confirmedOrganizationUsers, string defaultUserCollectionName)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if no collection name provided (backwards compatibility)
|
||||
if (string.IsNullOrWhiteSpace(defaultUserCollectionName))
|
||||
{
|
||||
@@ -349,4 +341,23 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
|
||||
await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the organization confirmed email using either the new mailer pattern or the legacy mail service,
|
||||
/// depending on the feature flag.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization the user was confirmed to.</param>
|
||||
/// <param name="userEmail">The email address of the confirmed user.</param>
|
||||
/// <param name="accessSecretsManager">Whether the user has access to Secrets Manager.</param>
|
||||
internal async Task SendOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager)
|
||||
{
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail))
|
||||
{
|
||||
await _sendOrganizationConfirmationCommand.SendConfirmationAsync(organization, userEmail, accessSecretsManager);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _mailService.SendOrganizationConfirmedEmailAsync(organization.DisplayName(), userEmail, accessSecretsManager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
|
||||
|
||||
public interface ISendOrganizationConfirmationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends an organization confirmation email to the specified user.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization to send the confirmation email for.</param>
|
||||
/// <param name="userEmail">The email address of the user to send the confirmation to.</param>
|
||||
/// <param name="accessSecretsManager">Whether the user has access to Secrets Manager.</param>
|
||||
Task SendConfirmationAsync(Organization organization, string userEmail, bool accessSecretsManager);
|
||||
|
||||
/// <summary>
|
||||
/// Sends organization confirmation emails to multiple users.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization to send the confirmation emails for.</param>
|
||||
/// <param name="userEmails">The email addresses of the users to send confirmations to.</param>
|
||||
/// <param name="accessSecretsManager">Whether the users have access to Secrets Manager.</param>
|
||||
Task SendConfirmationsAsync(Organization organization, IEnumerable<string> userEmails, bool accessSecretsManager);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Net;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
|
||||
|
||||
public class SendOrganizationConfirmationCommand(IMailer mailer, GlobalSettings globalSettings) : ISendOrganizationConfirmationCommand
|
||||
{
|
||||
private const string _titleFirst = "You're confirmed as a member of ";
|
||||
private const string _titleThird = "!";
|
||||
|
||||
private static string GetConfirmationSubject(string organizationName) =>
|
||||
$"You Have Been Confirmed To {organizationName}";
|
||||
private string GetWebVaultUrl(bool accessSecretsManager) => accessSecretsManager
|
||||
? globalSettings.BaseServiceUri.VaultWithHashAndSecretManagerProduct
|
||||
: globalSettings.BaseServiceUri.VaultWithHash;
|
||||
|
||||
public async Task SendConfirmationAsync(Organization organization, string userEmail, bool accessSecretsManager = false)
|
||||
{
|
||||
await SendConfirmationsAsync(organization, [userEmail], accessSecretsManager);
|
||||
}
|
||||
|
||||
public async Task SendConfirmationsAsync(Organization organization, IEnumerable<string> userEmails, bool accessSecretsManager = false)
|
||||
{
|
||||
var userEmailsList = userEmails.ToList();
|
||||
|
||||
if (userEmailsList.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var organizationName = WebUtility.HtmlDecode(organization.Name);
|
||||
|
||||
if (IsEnterpriseOrTeamsPlan(organization.PlanType))
|
||||
{
|
||||
await SendEnterpriseTeamsEmailsAsync(userEmailsList, organizationName, accessSecretsManager);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendFamilyFreeConfirmEmailsAsync(userEmailsList, organizationName, accessSecretsManager);
|
||||
}
|
||||
|
||||
private async Task SendEnterpriseTeamsEmailsAsync(List<string> userEmailsList, string organizationName, bool accessSecretsManager)
|
||||
{
|
||||
var mail = new OrganizationConfirmationEnterpriseTeams
|
||||
{
|
||||
ToEmails = userEmailsList,
|
||||
Subject = GetConfirmationSubject(organizationName),
|
||||
View = new OrganizationConfirmationEnterpriseTeamsView
|
||||
{
|
||||
OrganizationName = organizationName,
|
||||
TitleFirst = _titleFirst,
|
||||
TitleSecondBold = organizationName,
|
||||
TitleThird = _titleThird,
|
||||
WebVaultUrl = GetWebVaultUrl(accessSecretsManager)
|
||||
}
|
||||
};
|
||||
|
||||
await mailer.SendEmail(mail);
|
||||
}
|
||||
|
||||
private async Task SendFamilyFreeConfirmEmailsAsync(List<string> userEmailsList, string organizationName, bool accessSecretsManager)
|
||||
{
|
||||
var mail = new OrganizationConfirmationFamilyFree
|
||||
{
|
||||
ToEmails = userEmailsList,
|
||||
Subject = GetConfirmationSubject(organizationName),
|
||||
View = new OrganizationConfirmationFamilyFreeView
|
||||
{
|
||||
OrganizationName = organizationName,
|
||||
TitleFirst = _titleFirst,
|
||||
TitleSecondBold = organizationName,
|
||||
TitleThird = _titleThird,
|
||||
WebVaultUrl = GetWebVaultUrl(accessSecretsManager)
|
||||
}
|
||||
};
|
||||
|
||||
await mailer.SendEmail(mail);
|
||||
}
|
||||
|
||||
|
||||
private static bool IsEnterpriseOrTeamsPlan(PlanType planType)
|
||||
{
|
||||
return planType switch
|
||||
{
|
||||
PlanType.TeamsMonthly2019 or
|
||||
PlanType.TeamsAnnually2019 or
|
||||
PlanType.TeamsMonthly2020 or
|
||||
PlanType.TeamsAnnually2020 or
|
||||
PlanType.TeamsMonthly2023 or
|
||||
PlanType.TeamsAnnually2023 or
|
||||
PlanType.TeamsStarter2023 or
|
||||
PlanType.TeamsMonthly or
|
||||
PlanType.TeamsAnnually or
|
||||
PlanType.TeamsStarter or
|
||||
PlanType.EnterpriseMonthly2019 or
|
||||
PlanType.EnterpriseAnnually2019 or
|
||||
PlanType.EnterpriseMonthly2020 or
|
||||
PlanType.EnterpriseAnnually2020 or
|
||||
PlanType.EnterpriseMonthly2023 or
|
||||
PlanType.EnterpriseAnnually2023 or
|
||||
PlanType.EnterpriseMonthly or
|
||||
PlanType.EnterpriseAnnually => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Utilities.v2;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser;
|
||||
|
||||
public record OrganizationUserNotFound() : NotFoundError("Organization user not found.");
|
||||
public record NotEligibleForSelfRevoke() : BadRequestError("User is not eligible for self-revocation. The organization data ownership policy must be enabled and the user must be a confirmed member.");
|
||||
public record LastOwnerCannotSelfRevoke() : BadRequestError("The last owner cannot revoke themselves.");
|
||||
@@ -0,0 +1,22 @@
|
||||
using Bit.Core.AdminConsole.Utilities.v2.Results;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser;
|
||||
|
||||
/// <summary>
|
||||
/// Allows users to revoke themselves from an organization when declining to migrate personal items
|
||||
/// under the OrganizationDataOwnership policy.
|
||||
/// </summary>
|
||||
public interface ISelfRevokeOrganizationUserCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Revokes a user from an organization.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The organization ID.</param>
|
||||
/// <param name="userId">The user ID to revoke.</param>
|
||||
/// <returns>A <see cref="CommandResult"/> indicating success or containing an error.</returns>
|
||||
/// <remarks>
|
||||
/// Validates the OrganizationDataOwnership policy is enabled and applies to the user (currently Owners/Admins are exempt),
|
||||
/// the user is a confirmed member, and prevents the last owner from revoking themselves.
|
||||
/// </remarks>
|
||||
Task<CommandResult> SelfRevokeUserAsync(Guid organizationId, Guid userId);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user