mirror of
https://github.com/bitwarden/server.git
synced 2025-06-27 22:26:13 -05:00
Merge branch 'main' into ac/pm-19145-refactor-OrganizationService.ImportAsync
This commit is contained in:
commit
369de35f75
@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"swashbuckle.aspnetcore.cli": {
|
||||
"version": "7.2.0",
|
||||
"version": "7.3.2",
|
||||
"commands": ["swagger"]
|
||||
},
|
||||
"dotnet-ef": {
|
||||
|
42
.github/workflows/build.yml
vendored
42
.github/workflows/build.yml
vendored
@ -12,6 +12,9 @@ on:
|
||||
workflow_call:
|
||||
inputs: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||
_GITHUB_PR_REPO_NAME: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@ -237,18 +240,10 @@ jobs:
|
||||
fi
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate image full name
|
||||
id: cache-name
|
||||
env:
|
||||
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
|
||||
run: echo "name=${_AZ_REGISTRY}/${PROJECT_NAME}:buildcache" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build Docker image
|
||||
id: build-artifacts
|
||||
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
|
||||
with:
|
||||
cache-from: type=registry,ref=${{ steps.cache-name.outputs.name }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-name.outputs.name}},mode=max
|
||||
context: .
|
||||
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
|
||||
platforms: |
|
||||
@ -355,14 +350,6 @@ jobs:
|
||||
cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../..
|
||||
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
||||
|
||||
- name: Make Docker stub checksums
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
run: |
|
||||
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
|
||||
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
|
||||
|
||||
- name: Upload Docker stub US artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
@ -383,26 +370,6 @@ jobs:
|
||||
path: docker-stub-EU.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Docker stub US checksum artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: docker-stub-US-sha256.txt
|
||||
path: docker-stub-US-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Docker stub EU checksum artifact
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: docker-stub-EU-sha256.txt
|
||||
path: docker-stub-EU-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Build Public API Swagger
|
||||
run: |
|
||||
cd ./src/Api
|
||||
@ -603,8 +570,9 @@ jobs:
|
||||
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
|
||||
with:
|
||||
project: server
|
||||
pull_request_number: ${{ github.event.number }}
|
||||
pull_request_number: ${{ github.event.number || 0 }}
|
||||
secrets: inherit
|
||||
permissions: read-all
|
||||
|
||||
check-failures:
|
||||
name: Check for failures
|
||||
|
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@ -17,6 +17,9 @@ on:
|
||||
env:
|
||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
name: Setup
|
||||
@ -65,9 +68,7 @@ jobs:
|
||||
workflow_conclusion: success
|
||||
branch: ${{ needs.setup.outputs.branch-name }}
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
docker-stub-EU.zip,
|
||||
docker-stub-EU-sha256.txt,
|
||||
swagger.json"
|
||||
|
||||
- name: Dry Run - Download latest release Docker stubs
|
||||
@ -78,9 +79,7 @@ jobs:
|
||||
workflow_conclusion: success
|
||||
branch: main
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
docker-stub-EU.zip,
|
||||
docker-stub-EU-sha256.txt,
|
||||
swagger.json"
|
||||
|
||||
- name: Create release
|
||||
@ -88,9 +87,7 @@ jobs:
|
||||
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
|
||||
with:
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
docker-stub-EU.zip,
|
||||
docker-stub-EU-sha256.txt,
|
||||
swagger.json"
|
||||
commit: ${{ github.sha }}
|
||||
tag: "v${{ needs.setup.outputs.release_version }}"
|
||||
|
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.6.0</Version>
|
||||
<Version>2025.6.2</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
@ -550,6 +550,15 @@ public class ProviderBillingService(
|
||||
[
|
||||
new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber }
|
||||
];
|
||||
|
||||
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
|
||||
{
|
||||
options.TaxIdData.Add(new CustomerTaxIdDataOptions
|
||||
{
|
||||
Type = StripeConstants.TaxIdType.EUVAT,
|
||||
Value = $"ES{taxInfo.TaxIdNumber}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(provider.DiscountId))
|
||||
|
@ -499,9 +499,9 @@ public class AccountController : Controller
|
||||
// Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one
|
||||
if (orgUser == null && organization.Seats.HasValue)
|
||||
{
|
||||
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var occupiedSeats = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var initialSeatCount = organization.Seats.Value;
|
||||
var availableSeats = initialSeatCount - occupiedSeats;
|
||||
var availableSeats = initialSeatCount - occupiedSeats.Total;
|
||||
if (availableSeats < 1)
|
||||
{
|
||||
try
|
||||
|
@ -99,7 +99,7 @@ services:
|
||||
- idp
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:management
|
||||
image: rabbitmq:4.1.0-management
|
||||
container_name: rabbitmq
|
||||
ports:
|
||||
- "5672:5672"
|
||||
@ -108,7 +108,7 @@ services:
|
||||
RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER}
|
||||
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS}
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq_data
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
profiles:
|
||||
- rabbitmq
|
||||
|
||||
|
@ -11,7 +11,7 @@ $corsRules = (@{
|
||||
AllowedMethods = @("Get", "PUT");
|
||||
});
|
||||
$containers = "attachments", "sendfiles", "misc";
|
||||
$queues = "event", "notifications", "reference-events", "mail";
|
||||
$queues = "event", "notifications", "mail";
|
||||
$tables = "event", "metadata", "installationdevice";
|
||||
# End configuration
|
||||
|
||||
|
@ -5,6 +5,6 @@
|
||||
},
|
||||
"msbuild-sdks": {
|
||||
"Microsoft.Build.Traversal": "4.1.0",
|
||||
"Microsoft.Build.Sql": "0.1.9-preview"
|
||||
"Microsoft.Build.Sql": "1.0.0"
|
||||
}
|
||||
}
|
||||
|
@ -242,10 +242,32 @@ public class OrganizationsController : Controller
|
||||
Seats = organization.Seats
|
||||
};
|
||||
|
||||
if (model.PlanType.HasValue)
|
||||
{
|
||||
var freePlan = await _pricingClient.GetPlanOrThrow(model.PlanType.Value);
|
||||
var isDowngradingToFree = organization.PlanType != PlanType.Free && model.PlanType.Value == PlanType.Free;
|
||||
if (isDowngradingToFree)
|
||||
{
|
||||
if (model.Seats.HasValue && model.Seats.Value > freePlan.PasswordManager.MaxSeats)
|
||||
{
|
||||
TempData["Error"] = $"Organizations with more than {freePlan.PasswordManager.MaxSeats} seats cannot be downgraded to the Free plan";
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
if (model.MaxCollections > freePlan.PasswordManager.MaxCollections)
|
||||
{
|
||||
TempData["Error"] = $"Organizations with more than {freePlan.PasswordManager.MaxCollections} collections cannot be downgraded to the Free plan. Your organization currently has {organization.MaxCollections} collections.";
|
||||
return RedirectToAction("Edit", new { id });
|
||||
}
|
||||
|
||||
model.MaxStorageGb = null;
|
||||
model.ExpirationDate = null;
|
||||
model.Enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
UpdateOrganization(organization, model);
|
||||
|
||||
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
|
||||
if (organization.UseSecretsManager && !plan.SupportsSecretsManager)
|
||||
{
|
||||
TempData["Error"] = "Plan does not support Secrets Manager";
|
||||
|
@ -521,7 +521,9 @@ public class OrganizationUsersController : Controller
|
||||
.Concat(readonlyCollectionAccess)
|
||||
.ToList();
|
||||
|
||||
await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), userId,
|
||||
var existingUserType = organizationUser.Type;
|
||||
|
||||
await _updateOrganizationUserCommand.UpdateUserAsync(model.ToOrganizationUser(organizationUser), existingUserType, userId,
|
||||
collectionsToSave, groupsToSave);
|
||||
}
|
||||
|
||||
|
@ -177,9 +177,10 @@ public class MembersController : Controller
|
||||
{
|
||||
return new NotFoundResult();
|
||||
}
|
||||
var existingUserType = existingUser.Type;
|
||||
var updatedUser = model.ToOrganizationUser(existingUser);
|
||||
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList();
|
||||
await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, null, associations, model.Groups);
|
||||
await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, existingUserType, null, associations, model.Groups);
|
||||
MemberResponseModel response = null;
|
||||
if (existingUser.UserId.HasValue)
|
||||
{
|
||||
|
@ -34,7 +34,7 @@
|
||||
<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.25.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -4,6 +4,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.Billing.Models.Requests;
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Api.Billing.Queries.Organizations;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
@ -280,17 +281,36 @@ public class OrganizationBillingController(
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
return Error.NotFound();
|
||||
}
|
||||
var existingPlan = organization.PlanType;
|
||||
var organizationSignup = model.ToOrganizationSignup(user);
|
||||
var sale = OrganizationSale.From(organization, organizationSignup);
|
||||
var plan = await pricingClient.GetPlanOrThrow(model.PlanType);
|
||||
sale.Organization.PlanType = plan.Type;
|
||||
sale.Organization.Plan = plan.Name;
|
||||
sale.SubscriptionSetup.SkipTrial = true;
|
||||
if (existingPlan == PlanType.Free && organization.GatewaySubscriptionId is not null)
|
||||
{
|
||||
sale.Organization.UseTotp = plan.HasTotp;
|
||||
sale.Organization.UseGroups = plan.HasGroups;
|
||||
sale.Organization.UseDirectory = plan.HasDirectory;
|
||||
sale.Organization.SelfHost = plan.HasSelfHost;
|
||||
sale.Organization.UsersGetPremium = plan.UsersGetPremium;
|
||||
sale.Organization.UseEvents = plan.HasEvents;
|
||||
sale.Organization.Use2fa = plan.Has2fa;
|
||||
sale.Organization.UseApi = plan.HasApi;
|
||||
sale.Organization.UsePolicies = plan.HasPolicies;
|
||||
sale.Organization.UseSso = plan.HasSso;
|
||||
sale.Organization.UseResetPassword = plan.HasResetPassword;
|
||||
sale.Organization.UseKeyConnector = plan.HasKeyConnector;
|
||||
sale.Organization.UseScim = plan.HasScim;
|
||||
sale.Organization.UseCustomPermissions = plan.HasCustomPermissions;
|
||||
sale.Organization.UseOrganizationDomains = plan.HasOrganizationDomains;
|
||||
sale.Organization.MaxCollections = plan.PasswordManager.MaxCollections;
|
||||
}
|
||||
|
||||
if (organizationSignup.PaymentMethodType == null || string.IsNullOrEmpty(organizationSignup.PaymentToken))
|
||||
{
|
||||
|
@ -8,7 +8,7 @@ using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Tools.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("hibp")]
|
||||
[Authorize("Application")]
|
||||
|
@ -1,16 +1,16 @@
|
||||
using Bit.Api.Tools.Models;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Api.Dirt.Models;
|
||||
using Bit.Api.Dirt.Models.Response;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Dirt.Reports.Entities;
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Bit.Api.Tools.Controllers;
|
||||
namespace Bit.Api.Dirt.Controllers;
|
||||
|
||||
[Route("reports")]
|
||||
[Authorize("Application")]
|
||||
|
@ -1,4 +1,4 @@
|
||||
namespace Bit.Api.Tools.Models;
|
||||
namespace Bit.Api.Dirt.Models;
|
||||
|
||||
public class PasswordHealthReportApplicationModel
|
||||
{
|
||||
|
@ -1,6 +1,6 @@
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
|
||||
namespace Bit.Api.Tools.Models.Response;
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
/// <summary>
|
||||
/// Contains the collections and group collections a user has access to including
|
||||
|
@ -1,6 +1,6 @@
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
|
||||
namespace Bit.Api.Tools.Models.Response;
|
||||
namespace Bit.Api.Dirt.Models.Response;
|
||||
|
||||
public class MemberCipherDetailsResponseModel
|
||||
{
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.AdminConsole.Public.Models.Response;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Api.Models.Public.Response;
|
||||
@ -20,6 +21,7 @@ public class CollectionResponseModel : CollectionBaseModel, IResponseModel
|
||||
Id = collection.Id;
|
||||
ExternalId = collection.ExternalId;
|
||||
Groups = groups?.Select(c => new AssociationWithPermissionsResponseModel(c));
|
||||
Type = collection.Type;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -38,4 +40,8 @@ public class CollectionResponseModel : CollectionBaseModel, IResponseModel
|
||||
/// The associated groups that this collection is assigned to.
|
||||
/// </summary>
|
||||
public IEnumerable<AssociationWithPermissionsResponseModel> Groups { get; set; }
|
||||
/// <summary>
|
||||
/// The type of this collection
|
||||
/// </summary>
|
||||
public CollectionType Type { get; set; }
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Api;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
@ -18,12 +19,14 @@ public class CollectionResponseModel : ResponseModel
|
||||
OrganizationId = collection.OrganizationId;
|
||||
Name = collection.Name;
|
||||
ExternalId = collection.ExternalId;
|
||||
Type = collection.Type;
|
||||
}
|
||||
|
||||
public Guid Id { get; set; }
|
||||
public Guid OrganizationId { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string ExternalId { get; set; }
|
||||
public CollectionType Type { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -31,8 +31,8 @@ using Bit.Api.Billing;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
using Bit.Core.Tools.ImportFeatures;
|
||||
using Bit.Core.Tools.ReportFeatures;
|
||||
using Bit.Core.Auth.Models.Api.Request;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
using Bit.Core.Tools.SendFeatures;
|
||||
|
||||
#if !OSS
|
||||
|
@ -42,7 +42,6 @@ public class CiphersController : Controller
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ILogger<CiphersController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
@ -57,7 +56,6 @@ public class CiphersController : Controller
|
||||
ICurrentContext currentContext,
|
||||
ILogger<CiphersController> logger,
|
||||
GlobalSettings globalSettings,
|
||||
IFeatureService featureService,
|
||||
IOrganizationCiphersQuery organizationCiphersQuery,
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ICollectionRepository collectionRepository)
|
||||
@ -71,7 +69,6 @@ public class CiphersController : Controller
|
||||
_currentContext = currentContext;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
_featureService = featureService;
|
||||
_organizationCiphersQuery = organizationCiphersQuery;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_collectionRepository = collectionRepository;
|
||||
@ -375,11 +372,6 @@ public class CiphersController : Controller
|
||||
|
||||
private async Task<bool> CanDeleteOrRestoreCipherAsAdminAsync(Guid organizationId, IEnumerable<Guid> cipherIds)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.LimitItemDeletion))
|
||||
{
|
||||
return await CanEditCipherAsAdminAsync(organizationId, cipherIds);
|
||||
}
|
||||
|
||||
var org = _currentContext.GetOrganization(organizationId);
|
||||
|
||||
// If we're not an "admin" or if we're a provider user we don't need to check the ciphers
|
||||
@ -1064,7 +1056,7 @@ public class CiphersController : Controller
|
||||
|
||||
[HttpPut("share")]
|
||||
[HttpPost("share")]
|
||||
public async Task<CipherMiniResponseModel[]> PutShareMany([FromBody] CipherBulkShareRequestModel model)
|
||||
public async Task<ListResponseModel<CipherMiniResponseModel>> PutShareMany([FromBody] CipherBulkShareRequestModel model)
|
||||
{
|
||||
var organizationId = new Guid(model.Ciphers.First().OrganizationId);
|
||||
if (!await _currentContext.OrganizationUser(organizationId))
|
||||
@ -1086,7 +1078,7 @@ public class CiphersController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
var shareCiphers = new List<(Cipher, DateTime?)>();
|
||||
var shareCiphers = new List<(CipherDetails, DateTime?)>();
|
||||
foreach (var cipher in model.Ciphers)
|
||||
{
|
||||
if (!ciphersDict.TryGetValue(cipher.Id.Value, out var existingCipher))
|
||||
@ -1096,7 +1088,7 @@ public class CiphersController : Controller
|
||||
|
||||
ValidateClientVersionForFido2CredentialSupport(existingCipher);
|
||||
|
||||
shareCiphers.Add(((Cipher)existingCipher, cipher.LastKnownRevisionDate));
|
||||
shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate));
|
||||
}
|
||||
|
||||
var updated = await _cipherService.ShareManyAsync(
|
||||
@ -1106,7 +1098,8 @@ public class CiphersController : Controller
|
||||
userId
|
||||
);
|
||||
|
||||
return updated.Select(c => new CipherMiniResponseModel(c, _globalSettings, false)).ToArray();
|
||||
var response = updated.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp));
|
||||
return new ListResponseModel<CipherMiniResponseModel>(response);
|
||||
}
|
||||
|
||||
[HttpPost("purge")]
|
||||
|
@ -1,9 +1,7 @@
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Vault.Models.Request;
|
||||
using Bit.Api.Vault.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Commands.Interfaces;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Enums;
|
||||
@ -15,7 +13,6 @@ namespace Bit.Api.Vault.Controllers;
|
||||
|
||||
[Route("tasks")]
|
||||
[Authorize("Application")]
|
||||
[RequireFeature(FeatureFlagKeys.SecurityTasks)]
|
||||
public class SecurityTaskController : Controller
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
|
@ -10,7 +10,7 @@
|
||||
<ProjectReference Include="..\Core\Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Interfaces;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models;
|
||||
@ -9,23 +10,75 @@ using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// An association table between one <see cref="User"/> and one <see cref="Organization"/>, representing that user's
|
||||
/// membership in the organization. "Member" refers to the OrganizationUser object.
|
||||
/// </summary>
|
||||
public class OrganizationUser : ITableObject<Guid>, IExternal, IOrganizationUser
|
||||
{
|
||||
/// <summary>
|
||||
/// A unique random identifier.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// The ID of the Organization that the user is a member of.
|
||||
/// </summary>
|
||||
public Guid OrganizationId { get; set; }
|
||||
/// <summary>
|
||||
/// The ID of the User that is the member. This is NULL if the Status is Invited (or Invited and then Revoked), because
|
||||
/// it is not linked to a specific User yet.
|
||||
/// </summary>
|
||||
public Guid? UserId { get; set; }
|
||||
/// <summary>
|
||||
/// The email address of the user invited to the organization. This is NULL if the Status is not Invited (or
|
||||
/// Invited and then Revoked), because in that case the OrganizationUser is linked to a User
|
||||
/// and the email is stored on the User object.
|
||||
/// </summary>
|
||||
[MaxLength(256)]
|
||||
public string? Email { get; set; }
|
||||
/// <summary>
|
||||
/// The Organization symmetric key encrypted with the User's public key. NULL if the user is not in a Confirmed
|
||||
/// (or Confirmed and then Revoked) status.
|
||||
/// </summary>
|
||||
public string? Key { get; set; }
|
||||
/// <summary>
|
||||
/// The User's symmetric key encrypted with the Organization's public key. NULL if the OrganizationUser
|
||||
/// is not enrolled in account recovery.
|
||||
/// </summary>
|
||||
public string? ResetPasswordKey { get; set; }
|
||||
/// <inheritdoc cref="OrganizationUserStatusType"/>
|
||||
public OrganizationUserStatusType Status { get; set; }
|
||||
/// <summary>
|
||||
/// The User's role in the Organization.
|
||||
/// </summary>
|
||||
public OrganizationUserType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// An ID used to identify the OrganizationUser with an external directory service. Used by Directory Connector
|
||||
/// and SCIM.
|
||||
/// </summary>
|
||||
[MaxLength(300)]
|
||||
public string? ExternalId { get; set; }
|
||||
/// <summary>
|
||||
/// The date the OrganizationUser was created, i.e. when the User was first invited to the Organization.
|
||||
/// </summary>
|
||||
public DateTime CreationDate { get; internal set; } = DateTime.UtcNow;
|
||||
/// <summary>
|
||||
/// The last date the OrganizationUser entry was updated.
|
||||
/// </summary>
|
||||
public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow;
|
||||
/// <summary>
|
||||
/// A json blob representing the <see cref="Bit.Core.Models.Data.Permissions"/> of the OrganizationUser if they
|
||||
/// are a Custom user role (i.e. the <see cref="OrganizationUserType"/> is Custom). MAY be NULL if they are not
|
||||
/// a custom user, but this is not guaranteed; do not use this to determine their role.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Avoid using this property directly - instead use the <see cref="GetPermissions"/> and <see cref="SetPermissions"/>
|
||||
/// helper methods.
|
||||
/// </remarks>
|
||||
public string? Permissions { get; set; }
|
||||
/// <summary>
|
||||
/// True if the User has access to Secrets Manager for this Organization, false otherwise.
|
||||
/// </summary>
|
||||
public bool AccessSecretsManager { get; set; }
|
||||
|
||||
public void SetNewId()
|
||||
|
@ -1,9 +1,34 @@
|
||||
namespace Bit.Core.Enums;
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the different stages of a member's lifecycle in an organization.
|
||||
/// The <see cref="OrganizationUser"/> object is populated differently depending on their Status.
|
||||
/// </summary>
|
||||
public enum OrganizationUserStatusType : short
|
||||
{
|
||||
/// <summary>
|
||||
/// The OrganizationUser entry only represents an invitation to join the organization. It is not linked to a
|
||||
/// specific User yet.
|
||||
/// </summary>
|
||||
Invited = 0,
|
||||
/// <summary>
|
||||
/// The User has accepted the invitation and linked their User account to the OrganizationUser entry.
|
||||
/// </summary>
|
||||
Accepted = 1,
|
||||
/// <summary>
|
||||
/// An administrator has granted the User access to the organization. This is the final step in the User becoming
|
||||
/// a "full" member of the organization, including a key exchange so that they can decrypt organization data.
|
||||
/// </summary>
|
||||
Confirmed = 2,
|
||||
/// <summary>
|
||||
/// The OrganizationUser has been revoked from the organization and cannot access organization data while in this state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An OrganizationUser may move into this status from any other status, and will move back to their original status
|
||||
/// if restored. This allows an administrator to easily suspend and restore access without going through the
|
||||
/// Invite flow again.
|
||||
/// </remarks>
|
||||
Revoked = -1,
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ public enum PolicyType : byte
|
||||
AutomaticAppLogIn = 12,
|
||||
FreeFamiliesSponsorshipPolicy = 13,
|
||||
RemoveUnlockWithPin = 14,
|
||||
RestrictedItemTypesPolicy = 15,
|
||||
}
|
||||
|
||||
public static class PolicyTypeExtensions
|
||||
@ -43,7 +44,8 @@ public static class PolicyTypeExtensions
|
||||
PolicyType.ActivateAutofill => "Active auto-fill",
|
||||
PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications",
|
||||
PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship",
|
||||
PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN"
|
||||
PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN",
|
||||
PolicyType.RestrictedItemTypesPolicy => "Restricted item types",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,15 @@
|
||||
using Bit.Core.Enums;
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
public interface IIntegrationMessage
|
||||
{
|
||||
IntegrationType IntegrationType { get; }
|
||||
int RetryCount { get; set; }
|
||||
DateTime? DelayUntilDate { get; set; }
|
||||
string MessageId { get; set; }
|
||||
int RetryCount { get; }
|
||||
DateTime? DelayUntilDate { get; }
|
||||
void ApplyRetry(DateTime? handlerDelayUntilDate);
|
||||
string ToJson();
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
public class IntegrationHandlerResult
|
||||
{
|
||||
|
@ -1,13 +1,15 @@
|
||||
using System.Text.Json;
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
public class IntegrationMessage<T> : IIntegrationMessage
|
||||
public class IntegrationMessage : IIntegrationMessage
|
||||
{
|
||||
public IntegrationType IntegrationType { get; set; }
|
||||
public T Configuration { get; set; }
|
||||
public string RenderedTemplate { get; set; }
|
||||
public required string MessageId { get; set; }
|
||||
public required string RenderedTemplate { get; set; }
|
||||
public int RetryCount { get; set; } = 0;
|
||||
public DateTime? DelayUntilDate { get; set; }
|
||||
|
||||
@ -22,12 +24,22 @@ public class IntegrationMessage<T> : IIntegrationMessage
|
||||
DelayUntilDate = baseTime.AddSeconds(backoffSeconds + jitterSeconds);
|
||||
}
|
||||
|
||||
public string ToJson()
|
||||
public virtual string ToJson()
|
||||
{
|
||||
return JsonSerializer.Serialize(this);
|
||||
}
|
||||
}
|
||||
|
||||
public class IntegrationMessage<T> : IntegrationMessage
|
||||
{
|
||||
public required T Configuration { get; set; }
|
||||
|
||||
public override string ToJson()
|
||||
{
|
||||
return JsonSerializer.Serialize(this);
|
||||
}
|
||||
|
||||
public static IntegrationMessage<T> FromJson(string json)
|
||||
public static IntegrationMessage<T>? FromJson(string json)
|
||||
{
|
||||
return JsonSerializer.Deserialize<IntegrationMessage<T>>(json);
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
public record SlackIntegration(string token);
|
||||
|
@ -1,3 +1,5 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
public record SlackIntegrationConfiguration(string channelId);
|
||||
|
@ -1,3 +1,5 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
public record SlackIntegrationConfigurationDetails(string channelId, string token);
|
||||
|
@ -1,3 +1,5 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
public record WebhookIntegrationConfiguration(string url);
|
||||
|
@ -1,3 +1,5 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
public record WebhookIntegrationConfigurationDetails(string url);
|
||||
|
@ -1,4 +1,5 @@
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Core.Models.Slack;
|
||||
|
@ -1,11 +1,12 @@
|
||||
#nullable enable
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
|
||||
public interface IUpdateOrganizationUserCommand
|
||||
{
|
||||
Task UpdateUserAsync(OrganizationUser organizationUser, Guid? savingUserId,
|
||||
Task UpdateUserAsync(OrganizationUser organizationUser, OrganizationUserType existingUserType, Guid? savingUserId,
|
||||
List<CollectionAccessSelection>? collectionAccess, IEnumerable<Guid>? groupAccess);
|
||||
}
|
||||
|
@ -121,7 +121,7 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
|
||||
InviteOrganization = request.InviteOrganization,
|
||||
PerformedBy = request.PerformedBy,
|
||||
PerformedAt = request.PerformedAt,
|
||||
OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId),
|
||||
OccupiedPmSeats = (await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)).Total,
|
||||
OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)
|
||||
});
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.GlobalSettings;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Organization;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Utilities.Validation;
|
||||
@ -83,14 +84,9 @@ public class InviteUsersPasswordManagerValidator(
|
||||
return invalidEnvironment.Map(request);
|
||||
}
|
||||
|
||||
var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization);
|
||||
|
||||
if (organizationValidationResult is Invalid<InviteOrganization> organizationValidation)
|
||||
{
|
||||
return organizationValidation.Map(request);
|
||||
}
|
||||
|
||||
// Organizations managed by a provider need to be scaled by the provider. This needs to be checked in the event seats are increasing.
|
||||
var provider = await providerRepository.GetByOrganizationIdAsync(request.InviteOrganization.OrganizationId);
|
||||
|
||||
if (provider is not null)
|
||||
{
|
||||
var providerValidationResult = InvitingUserOrganizationProviderValidator.Validate(new InviteOrganizationProvider(provider));
|
||||
@ -101,6 +97,13 @@ public class InviteUsersPasswordManagerValidator(
|
||||
}
|
||||
}
|
||||
|
||||
var organizationValidationResult = await inviteUsersOrganizationValidator.ValidateAsync(request.InviteOrganization);
|
||||
|
||||
if (organizationValidationResult is Invalid<InviteOrganization> organizationValidation)
|
||||
{
|
||||
return organizationValidation.Map(request);
|
||||
}
|
||||
|
||||
var paymentSubscription = await paymentService.GetSubscriptionAsync(
|
||||
await organizationRepository.GetByIdAsync(request.InviteOrganization.OrganizationId));
|
||||
|
||||
|
@ -1,10 +1,9 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||
using Bit.Core.AdminConsole.Utilities.Validation;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation;
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation.Payments;
|
||||
|
||||
public static class InviteUserPaymentValidation
|
||||
{
|
||||
|
@ -70,8 +70,8 @@ public class RestoreOrganizationUserCommand(
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId);
|
||||
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
|
||||
var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total;
|
||||
|
||||
if (availableSeats < 1)
|
||||
{
|
||||
@ -163,8 +163,8 @@ public class RestoreOrganizationUserCommand(
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
|
||||
var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total;
|
||||
var newSeatsRequired = organizationUserIds.Count() - availableSeats;
|
||||
await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired);
|
||||
|
||||
|
@ -55,11 +55,13 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
|
||||
/// Update an organization user.
|
||||
/// </summary>
|
||||
/// <param name="organizationUser">The modified organization user to save.</param>
|
||||
/// <param name="existingUserType">The current type (member role) of the user.</param>
|
||||
/// <param name="savingUserId">The userId of the currently logged in user who is making the change.</param>
|
||||
/// <param name="collectionAccess">The user's updated collection access. If set to null, this removes all collection access.</param>
|
||||
/// <param name="groupAccess">The user's updated group access. If set to null, groups are not updated.</param>
|
||||
/// <exception cref="BadRequestException"></exception>
|
||||
public async Task UpdateUserAsync(OrganizationUser organizationUser, Guid? savingUserId,
|
||||
public async Task UpdateUserAsync(OrganizationUser organizationUser, OrganizationUserType existingUserType,
|
||||
Guid? savingUserId,
|
||||
List<CollectionAccessSelection>? collectionAccess, IEnumerable<Guid>? groupAccess)
|
||||
{
|
||||
// Avoid multiple enumeration
|
||||
@ -83,15 +85,7 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (organizationUser.UserId.HasValue && organization.PlanType == PlanType.Free && organizationUser.Type is OrganizationUserType.Admin or OrganizationUserType.Owner)
|
||||
{
|
||||
// Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this.
|
||||
var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(organizationUser.UserId.Value);
|
||||
if (adminCount > 0)
|
||||
{
|
||||
throw new BadRequestException("User can only be an admin of one free organization.");
|
||||
}
|
||||
}
|
||||
await EnsureUserCannotBeAdminOrOwnerForMultipleFreeOrganizationAsync(organizationUser, existingUserType, organization);
|
||||
|
||||
if (collectionAccessList.Count != 0)
|
||||
{
|
||||
@ -151,6 +145,40 @@ public class UpdateOrganizationUserCommand : IUpdateOrganizationUserCommand
|
||||
await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Updated);
|
||||
}
|
||||
|
||||
private async Task EnsureUserCannotBeAdminOrOwnerForMultipleFreeOrganizationAsync(OrganizationUser updatedOrgUser, OrganizationUserType existingUserType, Entities.Organization organization)
|
||||
{
|
||||
|
||||
if (organization.PlanType != PlanType.Free)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!updatedOrgUser.UserId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (updatedOrgUser.Type is not (OrganizationUserType.Admin or OrganizationUserType.Owner))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Since free organizations only supports a few users there is not much point in avoiding N+1 queries for this.
|
||||
var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(updatedOrgUser.UserId!.Value);
|
||||
|
||||
var isCurrentAdminOrOwner = existingUserType is OrganizationUserType.Admin or OrganizationUserType.Owner;
|
||||
|
||||
if (isCurrentAdminOrOwner && adminCount <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCurrentAdminOrOwner && adminCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new BadRequestException("User can only be an admin of one free organization.");
|
||||
}
|
||||
|
||||
private async Task ValidateCollectionAccessAsync(OrganizationUser originalUser,
|
||||
ICollection<CollectionAccessSelection> collectionAccess)
|
||||
{
|
||||
|
@ -104,8 +104,8 @@ public class TwoFactorAuthenticationPolicyValidator : IPolicyValidator
|
||||
throw new BadRequestException(string.Join(", ", commandResult.ErrorMessages));
|
||||
}
|
||||
|
||||
await Task.WhenAll(currentActiveRevocableOrganizationUsers.Select(x =>
|
||||
_mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), x.Email)));
|
||||
await Task.WhenAll(nonCompliantUsers.Select(nonCompliantUser =>
|
||||
_mailService.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(), nonCompliantUser.user.Email)));
|
||||
}
|
||||
|
||||
private static bool MembersWithNoMasterPasswordWillLoseAccess(
|
||||
|
@ -1,6 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
|
||||
#nullable enable
|
||||
|
||||
@ -25,4 +26,14 @@ public interface IOrganizationRepository : IRepository<Organization, Guid>
|
||||
Task<ICollection<Organization>> GetByVerifiedUserEmailDomainAsync(Guid userId);
|
||||
Task<ICollection<Organization>> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType);
|
||||
Task<ICollection<Organization>> GetManyByIdsAsync(IEnumerable<Guid> ids);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of occupied seats for an organization.
|
||||
/// OrganizationUsers occupy a seat, unless they are revoked.
|
||||
/// As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an
|
||||
/// organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The ID of the organization to get the occupied seat count for.</param>
|
||||
/// <returns>The number of occupied seats for the organization.</returns>
|
||||
Task<OrganizationSeatCounts> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId);
|
||||
}
|
||||
|
@ -18,16 +18,6 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
|
||||
Task<ICollection<OrganizationUser>> GetManyByUserAsync(Guid userId);
|
||||
Task<ICollection<OrganizationUser>> GetManyByOrganizationAsync(Guid organizationId, OrganizationUserType? type);
|
||||
Task<int> GetCountByOrganizationAsync(Guid organizationId, string email, bool onlyRegisteredUsers);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of occupied seats for an organization.
|
||||
/// Occupied seats are OrganizationUsers that have at least been invited.
|
||||
/// As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an
|
||||
/// organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The ID of the organization to get the occupied seat count for.</param>
|
||||
/// <returns>The number of occupied seats for the organization.</returns>
|
||||
Task<int> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId);
|
||||
Task<ICollection<string>> SelectKnownEmailsAsync(Guid organizationId, IEnumerable<string> emails, bool onlyRegisteredUsers);
|
||||
Task<OrganizationUser?> GetByOrganizationAsync(Guid organizationId, Guid userId);
|
||||
Task<Tuple<OrganizationUser?, ICollection<CollectionAccessSelection>>> GetByIdWithCollectionsAsync(Guid id);
|
||||
|
@ -1,13 +1,87 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Models.Data;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public abstract class EventLoggingListenerService : BackgroundService
|
||||
{
|
||||
protected readonly IEventMessageHandler _handler;
|
||||
protected ILogger<EventLoggingListenerService> _logger;
|
||||
|
||||
protected EventLoggingListenerService(IEventMessageHandler handler)
|
||||
protected EventLoggingListenerService(IEventMessageHandler handler, ILogger<EventLoggingListenerService> logger)
|
||||
{
|
||||
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
|
||||
_handler = handler;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
internal async Task ProcessReceivedMessageAsync(string body, string? messageId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var jsonDocument = JsonDocument.Parse(body);
|
||||
var root = jsonDocument.RootElement;
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var eventMessages = root.Deserialize<IEnumerable<EventMessage>>();
|
||||
await _handler.HandleManyEventsAsync(eventMessages);
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var eventMessage = root.Deserialize<EventMessage>();
|
||||
await _handler.HandleEventAsync(eventMessage);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrEmpty(messageId))
|
||||
{
|
||||
_logger.LogError("An error occurred while processing message: {MessageId} - Invalid JSON", messageId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("An Invalid JSON error occurred while processing a message with an empty message id");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(messageId))
|
||||
{
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"An error occurred while processing message: {MessageId} - Invalid JSON",
|
||||
messageId
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"An Invalid JSON error occurred while processing a message with an empty message id"
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(messageId))
|
||||
{
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"An error occurred while processing message: {MessageId}",
|
||||
messageId
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"An error occurred while processing a message with an empty message id"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
10
src/Core/AdminConsole/Services/IAzureServiceBusService.cs
Normal file
10
src/Core/AdminConsole/Services/IAzureServiceBusService.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public interface IAzureServiceBusService : IEventIntegrationPublisher, IAsyncDisposable
|
||||
{
|
||||
ServiceBusProcessor CreateProcessor(string topicName, string subscriptionName, ServiceBusProcessorOptions options);
|
||||
Task PublishToRetryAsync(IIntegrationMessage message);
|
||||
}
|
@ -2,7 +2,8 @@
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public interface IIntegrationPublisher
|
||||
public interface IEventIntegrationPublisher : IAsyncDisposable
|
||||
{
|
||||
Task PublishAsync(IIntegrationMessage message);
|
||||
Task PublishEventAsync(string body);
|
||||
}
|
19
src/Core/AdminConsole/Services/IRabbitMqService.cs
Normal file
19
src/Core/AdminConsole/Services/IRabbitMqService.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public interface IRabbitMqService : IEventIntegrationPublisher
|
||||
{
|
||||
Task<IChannel> CreateChannelAsync(CancellationToken cancellationToken = default);
|
||||
Task CreateEventQueueAsync(string queueName, CancellationToken cancellationToken = default);
|
||||
Task CreateIntegrationQueuesAsync(
|
||||
string queueName,
|
||||
string retryQueueName,
|
||||
string routingKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task PublishToRetryAsync(IChannel channel, IIntegrationMessage message, CancellationToken cancellationToken);
|
||||
Task PublishToDeadLetterAsync(IChannel channel, IIntegrationMessage message, CancellationToken cancellationToken);
|
||||
Task RepublishToRetryQueueAsync(IChannel channel, BasicDeliverEventArgs eventArgs);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
#nullable enable
|
||||
|
||||
using System.Text;
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@ -9,54 +9,31 @@ namespace Bit.Core.Services;
|
||||
|
||||
public class AzureServiceBusEventListenerService : EventLoggingListenerService
|
||||
{
|
||||
private readonly ILogger<AzureServiceBusEventListenerService> _logger;
|
||||
private readonly ServiceBusClient _client;
|
||||
private readonly ServiceBusProcessor _processor;
|
||||
|
||||
public AzureServiceBusEventListenerService(
|
||||
IEventMessageHandler handler,
|
||||
ILogger<AzureServiceBusEventListenerService> logger,
|
||||
IAzureServiceBusService serviceBusService,
|
||||
string subscriptionName,
|
||||
GlobalSettings globalSettings,
|
||||
string subscriptionName) : base(handler)
|
||||
ILogger<AzureServiceBusEventListenerService> logger) : base(handler, logger)
|
||||
{
|
||||
_client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString);
|
||||
_processor = _client.CreateProcessor(globalSettings.EventLogging.AzureServiceBus.EventTopicName, subscriptionName, new ServiceBusProcessorOptions());
|
||||
_processor = serviceBusService.CreateProcessor(
|
||||
globalSettings.EventLogging.AzureServiceBus.EventTopicName,
|
||||
subscriptionName,
|
||||
new ServiceBusProcessorOptions());
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_processor.ProcessMessageAsync += async args =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var jsonDocument = JsonDocument.Parse(Encoding.UTF8.GetString(args.Message.Body));
|
||||
var root = jsonDocument.RootElement;
|
||||
_processor.ProcessMessageAsync += ProcessReceivedMessageAsync;
|
||||
_processor.ProcessErrorAsync += ProcessErrorAsync;
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var eventMessages = root.Deserialize<IEnumerable<EventMessage>>();
|
||||
await _handler.HandleManyEventsAsync(eventMessages);
|
||||
await _processor.StartProcessingAsync(cancellationToken);
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var eventMessage = root.Deserialize<EventMessage>();
|
||||
await _handler.HandleEventAsync(eventMessage);
|
||||
|
||||
}
|
||||
await args.CompleteMessageAsync(args.Message);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(
|
||||
exception,
|
||||
"An error occured while processing message: {MessageId}",
|
||||
args.Message.MessageId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
_processor.ProcessErrorAsync += args =>
|
||||
internal Task ProcessErrorAsync(ProcessErrorEventArgs args)
|
||||
{
|
||||
_logger.LogError(
|
||||
args.Exception,
|
||||
@ -65,9 +42,12 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService
|
||||
args.ErrorSource
|
||||
);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
await _processor.StartProcessingAsync(cancellationToken);
|
||||
private async Task ProcessReceivedMessageAsync(ProcessMessageEventArgs args)
|
||||
{
|
||||
await ProcessReceivedMessageAsync(Encoding.UTF8.GetString(args.Message.Body), args.Message.MessageId);
|
||||
await args.CompleteMessageAsync(args.Message);
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
@ -79,7 +59,6 @@ public class AzureServiceBusEventListenerService : EventLoggingListenerService
|
||||
public override void Dispose()
|
||||
{
|
||||
_processor.DisposeAsync().GetAwaiter().GetResult();
|
||||
_client.DisposeAsync().GetAwaiter().GetResult();
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
|
@ -1,45 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Services.Implementations;
|
||||
|
||||
public class AzureServiceBusEventWriteService : IEventWriteService, IAsyncDisposable
|
||||
{
|
||||
private readonly ServiceBusClient _client;
|
||||
private readonly ServiceBusSender _sender;
|
||||
|
||||
public AzureServiceBusEventWriteService(GlobalSettings globalSettings)
|
||||
{
|
||||
_client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString);
|
||||
_sender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.EventTopicName);
|
||||
}
|
||||
|
||||
public async Task CreateAsync(IEvent e)
|
||||
{
|
||||
var message = new ServiceBusMessage(JsonSerializer.SerializeToUtf8Bytes(e))
|
||||
{
|
||||
ContentType = "application/json"
|
||||
};
|
||||
|
||||
await _sender.SendMessageAsync(message);
|
||||
}
|
||||
|
||||
public async Task CreateManyAsync(IEnumerable<IEvent> events)
|
||||
{
|
||||
var message = new ServiceBusMessage(JsonSerializer.SerializeToUtf8Bytes(events))
|
||||
{
|
||||
ContentType = "application/json"
|
||||
};
|
||||
|
||||
await _sender.SendMessageAsync(message);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _sender.DisposeAsync();
|
||||
await _client.DisposeAsync();
|
||||
}
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
#nullable enable
|
||||
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@ -10,39 +9,30 @@ namespace Bit.Core.Services;
|
||||
public class AzureServiceBusIntegrationListenerService : BackgroundService
|
||||
{
|
||||
private readonly int _maxRetries;
|
||||
private readonly string _subscriptionName;
|
||||
private readonly string _topicName;
|
||||
private readonly IAzureServiceBusService _serviceBusService;
|
||||
private readonly IIntegrationHandler _handler;
|
||||
private readonly ServiceBusClient _client;
|
||||
private readonly ServiceBusProcessor _processor;
|
||||
private readonly ServiceBusSender _sender;
|
||||
private readonly ILogger<AzureServiceBusIntegrationListenerService> _logger;
|
||||
|
||||
public AzureServiceBusIntegrationListenerService(
|
||||
IIntegrationHandler handler,
|
||||
public AzureServiceBusIntegrationListenerService(IIntegrationHandler handler,
|
||||
string topicName,
|
||||
string subscriptionName,
|
||||
GlobalSettings globalSettings,
|
||||
int maxRetries,
|
||||
IAzureServiceBusService serviceBusService,
|
||||
ILogger<AzureServiceBusIntegrationListenerService> logger)
|
||||
{
|
||||
_handler = handler;
|
||||
_logger = logger;
|
||||
_maxRetries = globalSettings.EventLogging.AzureServiceBus.MaxRetries;
|
||||
_topicName = globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName;
|
||||
_subscriptionName = subscriptionName;
|
||||
_maxRetries = maxRetries;
|
||||
_serviceBusService = serviceBusService;
|
||||
|
||||
_client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString);
|
||||
_processor = _client.CreateProcessor(_topicName, _subscriptionName, new ServiceBusProcessorOptions());
|
||||
_sender = _client.CreateSender(_topicName);
|
||||
_processor = _serviceBusService.CreateProcessor(topicName, subscriptionName, new ServiceBusProcessorOptions());
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_processor.ProcessMessageAsync += HandleMessageAsync;
|
||||
_processor.ProcessErrorAsync += args =>
|
||||
{
|
||||
_logger.LogError(args.Exception, "Azure Service Bus error");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
_processor.ProcessErrorAsync += ProcessErrorAsync;
|
||||
|
||||
await _processor.StartProcessingAsync(cancellationToken);
|
||||
}
|
||||
@ -51,51 +41,67 @@ public class AzureServiceBusIntegrationListenerService : BackgroundService
|
||||
{
|
||||
await _processor.StopProcessingAsync(cancellationToken);
|
||||
await _processor.DisposeAsync();
|
||||
await _sender.DisposeAsync();
|
||||
await _client.DisposeAsync();
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task HandleMessageAsync(ProcessMessageEventArgs args)
|
||||
internal Task ProcessErrorAsync(ProcessErrorEventArgs args)
|
||||
{
|
||||
var json = args.Message.Body.ToString();
|
||||
_logger.LogError(
|
||||
args.Exception,
|
||||
"An error occurred. Entity Path: {EntityPath}, Error Source: {ErrorSource}",
|
||||
args.EntityPath,
|
||||
args.ErrorSource
|
||||
);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal async Task<bool> HandleMessageAsync(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _handler.HandleAsync(json);
|
||||
var result = await _handler.HandleAsync(body);
|
||||
var message = result.Message;
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
await args.CompleteMessageAsync(args.Message);
|
||||
return;
|
||||
// Successful integration. Return true to indicate the message has been handled
|
||||
return true;
|
||||
}
|
||||
|
||||
message.ApplyRetry(result.DelayUntilDate);
|
||||
|
||||
if (result.Retryable && message.RetryCount < _maxRetries)
|
||||
{
|
||||
var scheduledTime = (DateTime)message.DelayUntilDate!;
|
||||
var retryMsg = new ServiceBusMessage(message.ToJson())
|
||||
// Publish message to the retry queue. It will be re-published for retry after a delay
|
||||
// Return true to indicate the message has been handled
|
||||
await _serviceBusService.PublishToRetryAsync(message);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Subject = args.Message.Subject,
|
||||
ScheduledEnqueueTime = scheduledTime
|
||||
};
|
||||
// Non-recoverable failure or exceeded the max number of retries
|
||||
// Return false to indicate this message should be dead-lettered
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Unknown exception - log error, return true so the message will be acknowledged and not resent
|
||||
_logger.LogError(ex, "Unhandled error processing ASB message");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await _sender.SendMessageAsync(retryMsg);
|
||||
private async Task HandleMessageAsync(ProcessMessageEventArgs args)
|
||||
{
|
||||
var json = args.Message.Body.ToString();
|
||||
if (await HandleMessageAsync(json))
|
||||
{
|
||||
await args.CompleteMessageAsync(args.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
await args.DeadLetterMessageAsync(args.Message, "Retry limit exceeded or non-retryable");
|
||||
return;
|
||||
}
|
||||
|
||||
await args.CompleteMessageAsync(args.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unhandled error processing ASB message");
|
||||
await args.CompleteMessageAsync(args.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,36 +0,0 @@
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class AzureServiceBusIntegrationPublisher : IIntegrationPublisher, IAsyncDisposable
|
||||
{
|
||||
private readonly ServiceBusClient _client;
|
||||
private readonly ServiceBusSender _sender;
|
||||
|
||||
public AzureServiceBusIntegrationPublisher(GlobalSettings globalSettings)
|
||||
{
|
||||
_client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString);
|
||||
_sender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName);
|
||||
}
|
||||
|
||||
public async Task PublishAsync(IIntegrationMessage message)
|
||||
{
|
||||
var json = message.ToJson();
|
||||
|
||||
var serviceBusMessage = new ServiceBusMessage(json)
|
||||
{
|
||||
Subject = message.IntegrationType.ToRoutingKey(),
|
||||
};
|
||||
|
||||
await _sender.SendMessageAsync(serviceBusMessage);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _sender.DisposeAsync();
|
||||
await _client.DisposeAsync();
|
||||
}
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
using Azure.Messaging.ServiceBus;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class AzureServiceBusService : IAzureServiceBusService
|
||||
{
|
||||
private readonly ServiceBusClient _client;
|
||||
private readonly ServiceBusSender _eventSender;
|
||||
private readonly ServiceBusSender _integrationSender;
|
||||
|
||||
public AzureServiceBusService(GlobalSettings globalSettings)
|
||||
{
|
||||
_client = new ServiceBusClient(globalSettings.EventLogging.AzureServiceBus.ConnectionString);
|
||||
_eventSender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.EventTopicName);
|
||||
_integrationSender = _client.CreateSender(globalSettings.EventLogging.AzureServiceBus.IntegrationTopicName);
|
||||
}
|
||||
|
||||
public ServiceBusProcessor CreateProcessor(string topicName, string subscriptionName, ServiceBusProcessorOptions options)
|
||||
{
|
||||
return _client.CreateProcessor(topicName, subscriptionName, options);
|
||||
}
|
||||
|
||||
public async Task PublishAsync(IIntegrationMessage message)
|
||||
{
|
||||
var json = message.ToJson();
|
||||
|
||||
var serviceBusMessage = new ServiceBusMessage(json)
|
||||
{
|
||||
Subject = message.IntegrationType.ToRoutingKey(),
|
||||
MessageId = message.MessageId
|
||||
};
|
||||
|
||||
await _integrationSender.SendMessageAsync(serviceBusMessage);
|
||||
}
|
||||
|
||||
public async Task PublishToRetryAsync(IIntegrationMessage message)
|
||||
{
|
||||
var json = message.ToJson();
|
||||
|
||||
var serviceBusMessage = new ServiceBusMessage(json)
|
||||
{
|
||||
Subject = message.IntegrationType.ToRoutingKey(),
|
||||
ScheduledEnqueueTime = message.DelayUntilDate ?? DateTime.UtcNow,
|
||||
MessageId = message.MessageId
|
||||
};
|
||||
|
||||
await _integrationSender.SendMessageAsync(serviceBusMessage);
|
||||
}
|
||||
|
||||
public async Task PublishEventAsync(string body)
|
||||
{
|
||||
var message = new ServiceBusMessage(body)
|
||||
{
|
||||
ContentType = "application/json",
|
||||
MessageId = Guid.NewGuid().ToString()
|
||||
};
|
||||
|
||||
await _eventSender.SendMessageAsync(message);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _eventSender.DisposeAsync();
|
||||
await _integrationSender.DisposeAsync();
|
||||
await _client.DisposeAsync();
|
||||
}
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
using Bit.Core.Models.Data;
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Models.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
@ -0,0 +1,32 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Models.Data;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
public class EventIntegrationEventWriteService : IEventWriteService, IAsyncDisposable
|
||||
{
|
||||
private readonly IEventIntegrationPublisher _eventIntegrationPublisher;
|
||||
|
||||
public EventIntegrationEventWriteService(IEventIntegrationPublisher eventIntegrationPublisher)
|
||||
{
|
||||
_eventIntegrationPublisher = eventIntegrationPublisher;
|
||||
}
|
||||
|
||||
public async Task CreateAsync(IEvent e)
|
||||
{
|
||||
var body = JsonSerializer.Serialize(e);
|
||||
await _eventIntegrationPublisher.PublishEventAsync(body: body);
|
||||
}
|
||||
|
||||
public async Task CreateManyAsync(IEnumerable<IEvent> events)
|
||||
{
|
||||
var body = JsonSerializer.Serialize(events);
|
||||
await _eventIntegrationPublisher.PublishEventAsync(body: body);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _eventIntegrationPublisher.DisposeAsync();
|
||||
}
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
using System.Text.Json;
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.AdminConsole.Utilities;
|
||||
using Bit.Core.Enums;
|
||||
@ -7,11 +9,9 @@ using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class EventIntegrationHandler<T>(
|
||||
IntegrationType integrationType,
|
||||
IIntegrationPublisher integrationPublisher,
|
||||
IEventIntegrationPublisher eventIntegrationPublisher,
|
||||
IOrganizationIntegrationConfigurationRepository configurationRepository,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository)
|
||||
@ -34,6 +34,7 @@ public class EventIntegrationHandler<T>(
|
||||
var template = configuration.Template ?? string.Empty;
|
||||
var context = await BuildContextAsync(eventMessage, template);
|
||||
var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(template, context);
|
||||
var messageId = eventMessage.IdempotencyId ?? Guid.NewGuid();
|
||||
|
||||
var config = configuration.MergedConfiguration.Deserialize<T>()
|
||||
?? throw new InvalidOperationException($"Failed to deserialize to {typeof(T).Name}");
|
||||
@ -41,13 +42,14 @@ public class EventIntegrationHandler<T>(
|
||||
var message = new IntegrationMessage<T>
|
||||
{
|
||||
IntegrationType = integrationType,
|
||||
MessageId = messageId.ToString(),
|
||||
Configuration = config,
|
||||
RenderedTemplate = renderedTemplate,
|
||||
RetryCount = 0,
|
||||
DelayUntilDate = null
|
||||
};
|
||||
|
||||
await integrationPublisher.PublishAsync(message);
|
||||
await eventIntegrationPublisher.PublishAsync(message);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
using Bit.Core.Models.Data;
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Models.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
@ -1,4 +1,6 @@
|
||||
using Bit.Core.Models.Data;
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Models.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
@ -294,13 +294,22 @@ public class OrganizationService : IOrganizationService
|
||||
|
||||
if (!organization.Seats.HasValue || organization.Seats.Value > newSeatTotal)
|
||||
{
|
||||
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
if (occupiedSeats > newSeatTotal)
|
||||
var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
|
||||
if (seatCounts.Total > newSeatTotal)
|
||||
{
|
||||
throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " +
|
||||
if (organization.UseAdminSponsoredFamilies || seatCounts.Sponsored > 0)
|
||||
{
|
||||
throw new BadRequestException($"Your organization has {seatCounts.Users} members and {seatCounts.Sponsored} sponsored families. " +
|
||||
$"To decrease the seat count below {seatCounts.Total}, you must remove members or sponsorships.");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new BadRequestException($"Your organization currently has {seatCounts.Total} seats filled. " +
|
||||
$"Your new plan only has ({newSeatTotal}) seats. Remove some users.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (organization.UseSecretsManager && organization.Seats + seatAdjustment < organization.SmSeats)
|
||||
{
|
||||
@ -726,8 +735,8 @@ public class OrganizationService : IOrganizationService
|
||||
var newSeatsRequired = 0;
|
||||
if (organization.Seats.HasValue)
|
||||
{
|
||||
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var availableSeats = organization.Seats.Value - occupiedSeats;
|
||||
var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var availableSeats = organization.Seats.Value - seatCounts.Total;
|
||||
newSeatsRequired = invites.Sum(i => i.invite.Emails.Count()) - existingEmails.Count() - availableSeats;
|
||||
}
|
||||
|
||||
@ -1177,8 +1186,8 @@ public class OrganizationService : IOrganizationService
|
||||
var enoughSeatsAvailable = true;
|
||||
if (organization.Seats.HasValue)
|
||||
{
|
||||
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
seatsAvailable = organization.Seats.Value - occupiedSeats;
|
||||
var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
seatsAvailable = organization.Seats.Value - seatCounts.Total;
|
||||
enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count;
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Settings;
|
||||
#nullable enable
|
||||
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
@ -10,94 +9,60 @@ namespace Bit.Core.Services;
|
||||
|
||||
public class RabbitMqEventListenerService : EventLoggingListenerService
|
||||
{
|
||||
private IChannel _channel;
|
||||
private IConnection _connection;
|
||||
private readonly string _exchangeName;
|
||||
private readonly ConnectionFactory _factory;
|
||||
private readonly ILogger<RabbitMqEventListenerService> _logger;
|
||||
private readonly Lazy<Task<IChannel>> _lazyChannel;
|
||||
private readonly string _queueName;
|
||||
private readonly IRabbitMqService _rabbitMqService;
|
||||
|
||||
public RabbitMqEventListenerService(
|
||||
IEventMessageHandler handler,
|
||||
ILogger<RabbitMqEventListenerService> logger,
|
||||
GlobalSettings globalSettings,
|
||||
string queueName) : base(handler)
|
||||
string queueName,
|
||||
IRabbitMqService rabbitMqService,
|
||||
ILogger<RabbitMqEventListenerService> logger) : base(handler, logger)
|
||||
{
|
||||
_factory = new ConnectionFactory
|
||||
{
|
||||
HostName = globalSettings.EventLogging.RabbitMq.HostName,
|
||||
UserName = globalSettings.EventLogging.RabbitMq.Username,
|
||||
Password = globalSettings.EventLogging.RabbitMq.Password
|
||||
};
|
||||
_exchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName;
|
||||
_logger = logger;
|
||||
_queueName = queueName;
|
||||
_rabbitMqService = rabbitMqService;
|
||||
_lazyChannel = new Lazy<Task<IChannel>>(() => _rabbitMqService.CreateChannelAsync());
|
||||
}
|
||||
|
||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_connection = await _factory.CreateConnectionAsync(cancellationToken);
|
||||
_channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
|
||||
|
||||
await _channel.ExchangeDeclareAsync(exchange: _exchangeName,
|
||||
type: ExchangeType.Fanout,
|
||||
durable: true,
|
||||
cancellationToken: cancellationToken);
|
||||
await _channel.QueueDeclareAsync(queue: _queueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: null,
|
||||
cancellationToken: cancellationToken);
|
||||
await _channel.QueueBindAsync(queue: _queueName,
|
||||
exchange: _exchangeName,
|
||||
routingKey: string.Empty,
|
||||
cancellationToken: cancellationToken);
|
||||
await _rabbitMqService.CreateEventQueueAsync(_queueName, cancellationToken);
|
||||
await base.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var consumer = new AsyncEventingBasicConsumer(_channel);
|
||||
consumer.ReceivedAsync += async (_, eventArgs) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var jsonDocument = JsonDocument.Parse(Encoding.UTF8.GetString(eventArgs.Body.Span));
|
||||
var root = jsonDocument.RootElement;
|
||||
var channel = await _lazyChannel.Value;
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.ReceivedAsync += async (_, eventArgs) => { await ProcessReceivedMessageAsync(eventArgs); };
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var eventMessages = root.Deserialize<IEnumerable<EventMessage>>();
|
||||
await _handler.HandleManyEventsAsync(eventMessages);
|
||||
await channel.BasicConsumeAsync(_queueName, autoAck: true, consumer: consumer, cancellationToken: cancellationToken);
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var eventMessage = root.Deserialize<EventMessage>();
|
||||
await _handler.HandleEventAsync(eventMessage);
|
||||
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
internal async Task ProcessReceivedMessageAsync(BasicDeliverEventArgs eventArgs)
|
||||
{
|
||||
_logger.LogError(ex, "An error occurred while processing the message");
|
||||
}
|
||||
};
|
||||
|
||||
await _channel.BasicConsumeAsync(_queueName, autoAck: true, consumer: consumer, cancellationToken: cancellationToken);
|
||||
await ProcessReceivedMessageAsync(
|
||||
Encoding.UTF8.GetString(eventArgs.Body.Span),
|
||||
eventArgs.BasicProperties.MessageId);
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _channel.CloseAsync(cancellationToken);
|
||||
await _connection.CloseAsync(cancellationToken);
|
||||
if (_lazyChannel.IsValueCreated)
|
||||
{
|
||||
var channel = await _lazyChannel.Value;
|
||||
await channel.CloseAsync(cancellationToken);
|
||||
}
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_channel.Dispose();
|
||||
_connection.Dispose();
|
||||
if (_lazyChannel.IsValueCreated && _lazyChannel.Value.IsCompletedSuccessfully)
|
||||
{
|
||||
_lazyChannel.Value.Result.Dispose();
|
||||
}
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
|
@ -1,62 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Settings;
|
||||
using RabbitMQ.Client;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
public class RabbitMqEventWriteService : IEventWriteService, IAsyncDisposable
|
||||
{
|
||||
private readonly ConnectionFactory _factory;
|
||||
private readonly Lazy<Task<IConnection>> _lazyConnection;
|
||||
private readonly string _exchangeName;
|
||||
|
||||
public RabbitMqEventWriteService(GlobalSettings globalSettings)
|
||||
{
|
||||
_factory = new ConnectionFactory
|
||||
{
|
||||
HostName = globalSettings.EventLogging.RabbitMq.HostName,
|
||||
UserName = globalSettings.EventLogging.RabbitMq.Username,
|
||||
Password = globalSettings.EventLogging.RabbitMq.Password
|
||||
};
|
||||
_exchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName;
|
||||
|
||||
_lazyConnection = new Lazy<Task<IConnection>>(CreateConnectionAsync);
|
||||
}
|
||||
|
||||
public async Task CreateAsync(IEvent e)
|
||||
{
|
||||
var connection = await _lazyConnection.Value;
|
||||
using var channel = await connection.CreateChannelAsync();
|
||||
|
||||
await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true);
|
||||
|
||||
var body = JsonSerializer.SerializeToUtf8Bytes(e);
|
||||
|
||||
await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: string.Empty, body: body);
|
||||
}
|
||||
|
||||
public async Task CreateManyAsync(IEnumerable<IEvent> events)
|
||||
{
|
||||
var connection = await _lazyConnection.Value;
|
||||
using var channel = await connection.CreateChannelAsync();
|
||||
await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Fanout, durable: true);
|
||||
|
||||
var body = JsonSerializer.SerializeToUtf8Bytes(events);
|
||||
|
||||
await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: string.Empty, body: body);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_lazyConnection.IsValueCreated)
|
||||
{
|
||||
var connection = await _lazyConnection.Value;
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IConnection> CreateConnectionAsync()
|
||||
{
|
||||
return await _factory.CreateConnectionAsync();
|
||||
}
|
||||
}
|
@ -1,5 +1,8 @@
|
||||
using System.Text;
|
||||
using Bit.Core.Settings;
|
||||
#nullable enable
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RabbitMQ.Client;
|
||||
@ -9,97 +12,39 @@ namespace Bit.Core.Services;
|
||||
|
||||
public class RabbitMqIntegrationListenerService : BackgroundService
|
||||
{
|
||||
private const string _deadLetterRoutingKey = "dead-letter";
|
||||
private IChannel _channel;
|
||||
private IConnection _connection;
|
||||
private readonly string _exchangeName;
|
||||
private readonly string _queueName;
|
||||
private readonly string _retryQueueName;
|
||||
private readonly string _deadLetterQueueName;
|
||||
private readonly string _routingKey;
|
||||
private readonly string _retryRoutingKey;
|
||||
private readonly int _maxRetries;
|
||||
private readonly string _queueName;
|
||||
private readonly string _routingKey;
|
||||
private readonly string _retryQueueName;
|
||||
private readonly IIntegrationHandler _handler;
|
||||
private readonly ConnectionFactory _factory;
|
||||
private readonly Lazy<Task<IChannel>> _lazyChannel;
|
||||
private readonly IRabbitMqService _rabbitMqService;
|
||||
private readonly ILogger<RabbitMqIntegrationListenerService> _logger;
|
||||
private readonly int _retryTiming;
|
||||
|
||||
public RabbitMqIntegrationListenerService(IIntegrationHandler handler,
|
||||
string routingKey,
|
||||
string queueName,
|
||||
string retryQueueName,
|
||||
string deadLetterQueueName,
|
||||
GlobalSettings globalSettings,
|
||||
int maxRetries,
|
||||
IRabbitMqService rabbitMqService,
|
||||
ILogger<RabbitMqIntegrationListenerService> logger)
|
||||
{
|
||||
_handler = handler;
|
||||
_routingKey = routingKey;
|
||||
_retryRoutingKey = $"{_routingKey}-retry";
|
||||
_queueName = queueName;
|
||||
_retryQueueName = retryQueueName;
|
||||
_deadLetterQueueName = deadLetterQueueName;
|
||||
_queueName = queueName;
|
||||
_rabbitMqService = rabbitMqService;
|
||||
_logger = logger;
|
||||
_exchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName;
|
||||
_maxRetries = globalSettings.EventLogging.RabbitMq.MaxRetries;
|
||||
_retryTiming = globalSettings.EventLogging.RabbitMq.RetryTiming;
|
||||
|
||||
_factory = new ConnectionFactory
|
||||
{
|
||||
HostName = globalSettings.EventLogging.RabbitMq.HostName,
|
||||
UserName = globalSettings.EventLogging.RabbitMq.Username,
|
||||
Password = globalSettings.EventLogging.RabbitMq.Password
|
||||
};
|
||||
_maxRetries = maxRetries;
|
||||
_lazyChannel = new Lazy<Task<IChannel>>(() => _rabbitMqService.CreateChannelAsync());
|
||||
}
|
||||
|
||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_connection = await _factory.CreateConnectionAsync(cancellationToken);
|
||||
_channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
|
||||
|
||||
await _channel.ExchangeDeclareAsync(exchange: _exchangeName,
|
||||
type: ExchangeType.Direct,
|
||||
durable: true,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
// Declare main queue
|
||||
await _channel.QueueDeclareAsync(queue: _queueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: null,
|
||||
cancellationToken: cancellationToken);
|
||||
await _channel.QueueBindAsync(queue: _queueName,
|
||||
exchange: _exchangeName,
|
||||
routingKey: _routingKey,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
// Declare retry queue (Configurable TTL, dead-letters back to main queue)
|
||||
await _channel.QueueDeclareAsync(queue: _retryQueueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: new Dictionary<string, object>
|
||||
{
|
||||
{ "x-dead-letter-exchange", _exchangeName },
|
||||
{ "x-dead-letter-routing-key", _routingKey },
|
||||
{ "x-message-ttl", _retryTiming }
|
||||
},
|
||||
cancellationToken: cancellationToken);
|
||||
await _channel.QueueBindAsync(queue: _retryQueueName,
|
||||
exchange: _exchangeName,
|
||||
routingKey: _retryRoutingKey,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
// Declare dead letter queue
|
||||
await _channel.QueueDeclareAsync(queue: _deadLetterQueueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: null,
|
||||
cancellationToken: cancellationToken);
|
||||
await _channel.QueueBindAsync(queue: _deadLetterQueueName,
|
||||
exchange: _exchangeName,
|
||||
routingKey: _deadLetterRoutingKey,
|
||||
await _rabbitMqService.CreateIntegrationQueuesAsync(
|
||||
_queueName,
|
||||
_retryQueueName,
|
||||
_routingKey,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
await base.StartAsync(cancellationToken);
|
||||
@ -107,20 +52,42 @@ public class RabbitMqIntegrationListenerService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var consumer = new AsyncEventingBasicConsumer(_channel);
|
||||
var channel = await _lazyChannel.Value;
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.ReceivedAsync += async (_, ea) =>
|
||||
{
|
||||
await ProcessReceivedMessageAsync(ea, cancellationToken);
|
||||
};
|
||||
|
||||
await channel.BasicConsumeAsync(queue: _queueName, autoAck: false, consumer: consumer, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
internal async Task ProcessReceivedMessageAsync(BasicDeliverEventArgs ea, CancellationToken cancellationToken)
|
||||
{
|
||||
var channel = await _lazyChannel.Value;
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(ea.Body.Span);
|
||||
|
||||
try
|
||||
// Determine if the message came off of the retry queue too soon
|
||||
// If so, place it back on the retry queue
|
||||
var integrationMessage = JsonSerializer.Deserialize<IntegrationMessage>(json);
|
||||
if (integrationMessage is not null &&
|
||||
integrationMessage.DelayUntilDate.HasValue &&
|
||||
integrationMessage.DelayUntilDate.Value > DateTime.UtcNow)
|
||||
{
|
||||
await _rabbitMqService.RepublishToRetryQueueAsync(channel, ea);
|
||||
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _handler.HandleAsync(json);
|
||||
var message = result.Message;
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Successful integration send. Acknowledge message delivery and return
|
||||
await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
|
||||
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -132,60 +99,50 @@ public class RabbitMqIntegrationListenerService : BackgroundService
|
||||
if (message.RetryCount < _maxRetries)
|
||||
{
|
||||
// Publish message to the retry queue. It will be re-published for retry after a delay
|
||||
await _channel.BasicPublishAsync(
|
||||
exchange: _exchangeName,
|
||||
routingKey: _retryRoutingKey,
|
||||
body: Encoding.UTF8.GetBytes(message.ToJson()),
|
||||
cancellationToken: cancellationToken);
|
||||
await _rabbitMqService.PublishToRetryAsync(channel, message, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Exceeded the max number of retries; fail and send to dead letter queue
|
||||
await PublishToDeadLetterAsync(message.ToJson());
|
||||
await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken);
|
||||
_logger.LogWarning("Max retry attempts reached. Sent to DLQ.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fatal error (i.e. not retryable) occurred. Send message to dead letter queue without any retries
|
||||
await PublishToDeadLetterAsync(message.ToJson());
|
||||
await _rabbitMqService.PublishToDeadLetterAsync(channel, message, cancellationToken);
|
||||
_logger.LogWarning("Non-retryable failure. Sent to DLQ.");
|
||||
}
|
||||
|
||||
// Message has been sent to retry or dead letter queues.
|
||||
// Acknowledge receipt so Rabbit knows it's been processed
|
||||
await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
|
||||
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Unknown error occurred. Acknowledge so Rabbit doesn't keep attempting. Log the error
|
||||
_logger.LogError(ex, "Unhandled error processing integration message.");
|
||||
await _channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
|
||||
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
|
||||
}
|
||||
};
|
||||
|
||||
await _channel.BasicConsumeAsync(queue: _queueName, autoAck: false, consumer: consumer, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private async Task PublishToDeadLetterAsync(string json)
|
||||
{
|
||||
await _channel.BasicPublishAsync(
|
||||
exchange: _exchangeName,
|
||||
routingKey: _deadLetterRoutingKey,
|
||||
body: Encoding.UTF8.GetBytes(json));
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _channel.CloseAsync(cancellationToken);
|
||||
await _connection.CloseAsync(cancellationToken);
|
||||
if (_lazyChannel.IsValueCreated)
|
||||
{
|
||||
var channel = await _lazyChannel.Value;
|
||||
await channel.CloseAsync(cancellationToken);
|
||||
}
|
||||
await base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_channel.Dispose();
|
||||
_connection.Dispose();
|
||||
if (_lazyChannel.IsValueCreated && _lazyChannel.Value.IsCompletedSuccessfully)
|
||||
{
|
||||
_lazyChannel.Value.Result.Dispose();
|
||||
}
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
|
@ -1,54 +0,0 @@
|
||||
using System.Text;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
using RabbitMQ.Client;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class RabbitMqIntegrationPublisher : IIntegrationPublisher, IAsyncDisposable
|
||||
{
|
||||
private readonly ConnectionFactory _factory;
|
||||
private readonly Lazy<Task<IConnection>> _lazyConnection;
|
||||
private readonly string _exchangeName;
|
||||
|
||||
public RabbitMqIntegrationPublisher(GlobalSettings globalSettings)
|
||||
{
|
||||
_factory = new ConnectionFactory
|
||||
{
|
||||
HostName = globalSettings.EventLogging.RabbitMq.HostName,
|
||||
UserName = globalSettings.EventLogging.RabbitMq.Username,
|
||||
Password = globalSettings.EventLogging.RabbitMq.Password
|
||||
};
|
||||
_exchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName;
|
||||
|
||||
_lazyConnection = new Lazy<Task<IConnection>>(CreateConnectionAsync);
|
||||
}
|
||||
|
||||
public async Task PublishAsync(IIntegrationMessage message)
|
||||
{
|
||||
var routingKey = message.IntegrationType.ToRoutingKey();
|
||||
var connection = await _lazyConnection.Value;
|
||||
await using var channel = await connection.CreateChannelAsync();
|
||||
|
||||
await channel.ExchangeDeclareAsync(exchange: _exchangeName, type: ExchangeType.Direct, durable: true);
|
||||
|
||||
var body = Encoding.UTF8.GetBytes(message.ToJson());
|
||||
|
||||
await channel.BasicPublishAsync(exchange: _exchangeName, routingKey: routingKey, body: body);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_lazyConnection.IsValueCreated)
|
||||
{
|
||||
var connection = await _lazyConnection.Value;
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IConnection> CreateConnectionAsync()
|
||||
{
|
||||
return await _factory.CreateConnectionAsync();
|
||||
}
|
||||
}
|
@ -0,0 +1,244 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Settings;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class RabbitMqService : IRabbitMqService
|
||||
{
|
||||
private const string _deadLetterRoutingKey = "dead-letter";
|
||||
|
||||
private readonly ConnectionFactory _factory;
|
||||
private readonly Lazy<Task<IConnection>> _lazyConnection;
|
||||
private readonly string _deadLetterQueueName;
|
||||
private readonly string _eventExchangeName;
|
||||
private readonly string _integrationExchangeName;
|
||||
private readonly int _retryTiming;
|
||||
private readonly bool _useDelayPlugin;
|
||||
|
||||
public RabbitMqService(GlobalSettings globalSettings)
|
||||
{
|
||||
_factory = new ConnectionFactory
|
||||
{
|
||||
HostName = globalSettings.EventLogging.RabbitMq.HostName,
|
||||
UserName = globalSettings.EventLogging.RabbitMq.Username,
|
||||
Password = globalSettings.EventLogging.RabbitMq.Password
|
||||
};
|
||||
_deadLetterQueueName = globalSettings.EventLogging.RabbitMq.IntegrationDeadLetterQueueName;
|
||||
_eventExchangeName = globalSettings.EventLogging.RabbitMq.EventExchangeName;
|
||||
_integrationExchangeName = globalSettings.EventLogging.RabbitMq.IntegrationExchangeName;
|
||||
_retryTiming = globalSettings.EventLogging.RabbitMq.RetryTiming;
|
||||
_useDelayPlugin = globalSettings.EventLogging.RabbitMq.UseDelayPlugin;
|
||||
|
||||
_lazyConnection = new Lazy<Task<IConnection>>(CreateConnectionAsync);
|
||||
}
|
||||
|
||||
public async Task<IChannel> CreateChannelAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = await _lazyConnection.Value;
|
||||
return await connection.CreateChannelAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task CreateEventQueueAsync(string queueName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var channel = await CreateChannelAsync(cancellationToken);
|
||||
await channel.QueueDeclareAsync(queue: queueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: null,
|
||||
cancellationToken: cancellationToken);
|
||||
await channel.QueueBindAsync(queue: queueName,
|
||||
exchange: _eventExchangeName,
|
||||
routingKey: string.Empty,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task CreateIntegrationQueuesAsync(
|
||||
string queueName,
|
||||
string retryQueueName,
|
||||
string routingKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var channel = await CreateChannelAsync(cancellationToken);
|
||||
var retryRoutingKey = $"{routingKey}-retry";
|
||||
|
||||
// Declare main integration queue
|
||||
await channel.QueueDeclareAsync(
|
||||
queue: queueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: null,
|
||||
cancellationToken: cancellationToken);
|
||||
await channel.QueueBindAsync(
|
||||
queue: queueName,
|
||||
exchange: _integrationExchangeName,
|
||||
routingKey: routingKey,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (!_useDelayPlugin)
|
||||
{
|
||||
// Declare retry queue (Configurable TTL, dead-letters back to main queue)
|
||||
// Only needed if NOT using delay plugin
|
||||
await channel.QueueDeclareAsync(queue: retryQueueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: new Dictionary<string, object?>
|
||||
{
|
||||
{ "x-dead-letter-exchange", _integrationExchangeName },
|
||||
{ "x-dead-letter-routing-key", routingKey },
|
||||
{ "x-message-ttl", _retryTiming }
|
||||
},
|
||||
cancellationToken: cancellationToken);
|
||||
await channel.QueueBindAsync(queue: retryQueueName,
|
||||
exchange: _integrationExchangeName,
|
||||
routingKey: retryRoutingKey,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PublishAsync(IIntegrationMessage message)
|
||||
{
|
||||
var routingKey = message.IntegrationType.ToRoutingKey();
|
||||
await using var channel = await CreateChannelAsync();
|
||||
|
||||
var body = Encoding.UTF8.GetBytes(message.ToJson());
|
||||
var properties = new BasicProperties
|
||||
{
|
||||
MessageId = message.MessageId,
|
||||
Persistent = true
|
||||
};
|
||||
|
||||
await channel.BasicPublishAsync(
|
||||
exchange: _integrationExchangeName,
|
||||
mandatory: true,
|
||||
basicProperties: properties,
|
||||
routingKey: routingKey,
|
||||
body: body);
|
||||
}
|
||||
|
||||
public async Task PublishEventAsync(string body)
|
||||
{
|
||||
await using var channel = await CreateChannelAsync();
|
||||
var properties = new BasicProperties
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString(),
|
||||
Persistent = true
|
||||
};
|
||||
|
||||
await channel.BasicPublishAsync(
|
||||
exchange: _eventExchangeName,
|
||||
mandatory: true,
|
||||
basicProperties: properties,
|
||||
routingKey: string.Empty,
|
||||
body: Encoding.UTF8.GetBytes(body));
|
||||
}
|
||||
|
||||
public async Task PublishToRetryAsync(IChannel channel, IIntegrationMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
var routingKey = message.IntegrationType.ToRoutingKey();
|
||||
var retryRoutingKey = $"{routingKey}-retry";
|
||||
var properties = new BasicProperties
|
||||
{
|
||||
Persistent = true,
|
||||
MessageId = message.MessageId,
|
||||
Headers = _useDelayPlugin && message.DelayUntilDate.HasValue ?
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["x-delay"] = Math.Max((int)(message.DelayUntilDate.Value - DateTime.UtcNow).TotalMilliseconds, 0)
|
||||
} :
|
||||
null
|
||||
};
|
||||
|
||||
await channel.BasicPublishAsync(
|
||||
exchange: _integrationExchangeName,
|
||||
routingKey: _useDelayPlugin ? routingKey : retryRoutingKey,
|
||||
mandatory: true,
|
||||
basicProperties: properties,
|
||||
body: Encoding.UTF8.GetBytes(message.ToJson()),
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task PublishToDeadLetterAsync(
|
||||
IChannel channel,
|
||||
IIntegrationMessage message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var properties = new BasicProperties
|
||||
{
|
||||
MessageId = message.MessageId,
|
||||
Persistent = true
|
||||
};
|
||||
|
||||
await channel.BasicPublishAsync(
|
||||
exchange: _integrationExchangeName,
|
||||
mandatory: true,
|
||||
basicProperties: properties,
|
||||
routingKey: _deadLetterRoutingKey,
|
||||
body: Encoding.UTF8.GetBytes(message.ToJson()),
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RepublishToRetryQueueAsync(IChannel channel, BasicDeliverEventArgs eventArgs)
|
||||
{
|
||||
await channel.BasicPublishAsync(
|
||||
exchange: _integrationExchangeName,
|
||||
routingKey: eventArgs.RoutingKey,
|
||||
mandatory: true,
|
||||
basicProperties: new BasicProperties(eventArgs.BasicProperties),
|
||||
body: eventArgs.Body);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_lazyConnection.IsValueCreated)
|
||||
{
|
||||
var connection = await _lazyConnection.Value;
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IConnection> CreateConnectionAsync()
|
||||
{
|
||||
var connection = await _factory.CreateConnectionAsync();
|
||||
using var channel = await connection.CreateChannelAsync();
|
||||
|
||||
// Declare Exchanges
|
||||
await channel.ExchangeDeclareAsync(exchange: _eventExchangeName, type: ExchangeType.Fanout, durable: true);
|
||||
if (_useDelayPlugin)
|
||||
{
|
||||
await channel.ExchangeDeclareAsync(
|
||||
exchange: _integrationExchangeName,
|
||||
type: "x-delayed-message",
|
||||
durable: true,
|
||||
arguments: new Dictionary<string, object?>
|
||||
{
|
||||
{ "x-delayed-type", "direct" }
|
||||
}
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
await channel.ExchangeDeclareAsync(exchange: _integrationExchangeName, type: ExchangeType.Direct, durable: true);
|
||||
}
|
||||
|
||||
// Declare dead letter queue for Integration exchange
|
||||
await channel.QueueDeclareAsync(queue: _deadLetterQueueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: null);
|
||||
await channel.QueueBindAsync(queue: _deadLetterQueueName,
|
||||
exchange: _integrationExchangeName,
|
||||
routingKey: _deadLetterRoutingKey);
|
||||
|
||||
return connection;
|
||||
}
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
using System.Net.Http.Headers;
|
||||
#nullable enable
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Web;
|
||||
using Bit.Core.Models.Slack;
|
||||
@ -22,7 +24,7 @@ public class SlackService(
|
||||
|
||||
public async Task<string> GetChannelIdAsync(string token, string channelName)
|
||||
{
|
||||
return (await GetChannelIdsAsync(token, [channelName])).FirstOrDefault();
|
||||
return (await GetChannelIdsAsync(token, [channelName])).FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames)
|
||||
@ -58,7 +60,7 @@ public class SlackService(
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogError("Error getting Channel Ids: {Error}", result.Error);
|
||||
logger.LogError("Error getting Channel Ids: {Error}", result?.Error ?? "Unknown Error");
|
||||
nextCursor = string.Empty;
|
||||
}
|
||||
|
||||
@ -89,7 +91,7 @@ public class SlackService(
|
||||
new KeyValuePair<string, string>("redirect_uri", redirectUrl)
|
||||
}));
|
||||
|
||||
SlackOAuthResponse result;
|
||||
SlackOAuthResponse? result;
|
||||
try
|
||||
{
|
||||
result = await tokenResponse.Content.ReadFromJsonAsync<SlackOAuthResponse>();
|
||||
@ -99,7 +101,7 @@ public class SlackService(
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result == null)
|
||||
if (result is null)
|
||||
{
|
||||
logger.LogError("Error obtaining token via OAuth: Unknown error");
|
||||
return string.Empty;
|
||||
@ -130,6 +132,11 @@ public class SlackService(
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
var result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
logger.LogError("Error retrieving Slack user ID: Unknown error");
|
||||
return string.Empty;
|
||||
}
|
||||
if (!result.Ok)
|
||||
{
|
||||
logger.LogError("Error retrieving Slack user ID: {Error}", result.Error);
|
||||
@ -151,6 +158,11 @@ public class SlackService(
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
var result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
logger.LogError("Error opening DM channel: Unknown error");
|
||||
return string.Empty;
|
||||
}
|
||||
if (!result.Ok)
|
||||
{
|
||||
logger.LogError("Error opening DM channel: {Error}", result.Error);
|
||||
|
@ -1,4 +1,6 @@
|
||||
using System.Globalization;
|
||||
#nullable enable
|
||||
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Bit.Core.AdminConsole.Models.Data.Integrations;
|
||||
@ -29,7 +31,7 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
|
||||
case HttpStatusCode.ServiceUnavailable:
|
||||
case HttpStatusCode.GatewayTimeout:
|
||||
result.Retryable = true;
|
||||
result.FailureReason = response.ReasonPhrase;
|
||||
result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}";
|
||||
|
||||
if (response.Headers.TryGetValues("Retry-After", out var values))
|
||||
{
|
||||
@ -52,7 +54,7 @@ public class WebhookIntegrationHandler(IHttpClientFactory httpClientFactory)
|
||||
break;
|
||||
default:
|
||||
result.Retryable = false;
|
||||
result.FailureReason = response.ReasonPhrase;
|
||||
result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}";
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -96,6 +96,12 @@ public static class StripeConstants
|
||||
public const string Reverse = "reverse";
|
||||
}
|
||||
|
||||
public static class TaxIdType
|
||||
{
|
||||
public const string EUVAT = "eu_vat";
|
||||
public const string SpanishNIF = "es_cif";
|
||||
}
|
||||
|
||||
public static class ValidateTaxLocationTiming
|
||||
{
|
||||
public const string Deferred = "deferred";
|
||||
|
@ -31,6 +31,7 @@ public record PlanAdapter : Plan
|
||||
HasScim = HasFeature("scim");
|
||||
HasResetPassword = HasFeature("resetPassword");
|
||||
UsersGetPremium = HasFeature("usersGetPremium");
|
||||
HasCustomPermissions = HasFeature("customPermissions");
|
||||
UpgradeSortOrder = plan.AdditionalData.TryGetValue("upgradeSortOrder", out var upgradeSortOrder)
|
||||
? int.Parse(upgradeSortOrder)
|
||||
: 0;
|
||||
@ -141,6 +142,7 @@ public record PlanAdapter : Plan
|
||||
var stripeSeatPlanId = GetStripeSeatPlanId(seats);
|
||||
var hasAdditionalSeatsOption = seats.IsScalable;
|
||||
var seatPrice = GetSeatPrice(seats);
|
||||
var baseSeats = GetBaseSeats(seats);
|
||||
var maxSeats = GetMaxSeats(seats);
|
||||
var allowSeatAutoscale = seats.IsScalable;
|
||||
var maxProjects = plan.AdditionalData.TryGetValue("secretsManager.maxProjects", out var value) ? short.Parse(value) : 0;
|
||||
@ -156,6 +158,7 @@ public record PlanAdapter : Plan
|
||||
StripeSeatPlanId = stripeSeatPlanId,
|
||||
HasAdditionalSeatsOption = hasAdditionalSeatsOption,
|
||||
SeatPrice = seatPrice,
|
||||
BaseSeats = baseSeats,
|
||||
MaxSeats = maxSeats,
|
||||
AllowSeatAutoscale = allowSeatAutoscale,
|
||||
MaxProjects = maxProjects
|
||||
@ -168,8 +171,16 @@ public record PlanAdapter : Plan
|
||||
private static decimal GetBasePrice(PurchasableDTO purchasable)
|
||||
=> purchasable.FromPackaged(x => x.Price);
|
||||
|
||||
private static int GetBaseSeats(FreeOrScalableDTO freeOrScalable)
|
||||
=> freeOrScalable.Match(
|
||||
free => free.Quantity,
|
||||
scalable => scalable.Provided);
|
||||
|
||||
private static int GetBaseSeats(PurchasableDTO purchasable)
|
||||
=> purchasable.FromPackaged(x => x.Quantity);
|
||||
=> purchasable.Match(
|
||||
free => free.Quantity,
|
||||
packaged => packaged.Quantity,
|
||||
scalable => scalable.Provided);
|
||||
|
||||
private static short GetBaseServiceAccount(FreeOrScalableDTO freeOrScalable)
|
||||
=> freeOrScalable.Match(
|
||||
|
@ -31,7 +31,6 @@ public class OrganizationBillingService(
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<OrganizationBillingService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IPricingClient pricingClient,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
@ -78,13 +77,14 @@ public class OrganizationBillingService(
|
||||
var isEligibleForSelfHost = await IsEligibleForSelfHostAsync(organization);
|
||||
|
||||
var isManaged = organization.Status == OrganizationStatusType.Managed;
|
||||
|
||||
var orgOccupiedSeats = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
|
||||
{
|
||||
return OrganizationMetadata.Default with
|
||||
{
|
||||
IsEligibleForSelfHost = isEligibleForSelfHost,
|
||||
IsManaged = isManaged
|
||||
IsManaged = isManaged,
|
||||
OrganizationOccupiedSeats = orgOccupiedSeats.Total
|
||||
};
|
||||
}
|
||||
|
||||
@ -108,8 +108,6 @@ public class OrganizationBillingService(
|
||||
? await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions())
|
||||
: null;
|
||||
|
||||
var orgOccupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
|
||||
return new OrganizationMetadata(
|
||||
isEligibleForSelfHost,
|
||||
isManaged,
|
||||
@ -121,7 +119,7 @@ public class OrganizationBillingService(
|
||||
invoice?.DueDate,
|
||||
invoice?.Created,
|
||||
subscription.CurrentPeriodEnd,
|
||||
orgOccupiedSeats);
|
||||
orgOccupiedSeats.Total);
|
||||
}
|
||||
|
||||
public async Task
|
||||
@ -248,12 +246,23 @@ public class OrganizationBillingService(
|
||||
organization.Id,
|
||||
customerSetup.TaxInformation.Country,
|
||||
customerSetup.TaxInformation.TaxId);
|
||||
|
||||
throw new BadRequestException("billingTaxIdTypeInferenceError");
|
||||
}
|
||||
|
||||
customerCreateOptions.TaxIdData =
|
||||
[
|
||||
new() { Type = taxIdType, Value = customerSetup.TaxInformation.TaxId }
|
||||
];
|
||||
|
||||
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
|
||||
{
|
||||
customerCreateOptions.TaxIdData.Add(new CustomerTaxIdDataOptions
|
||||
{
|
||||
Type = StripeConstants.TaxIdType.EUVAT,
|
||||
Value = $"ES{customerSetup.TaxInformation.TaxId}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource;
|
||||
@ -420,7 +429,7 @@ public class OrganizationBillingService(
|
||||
var setNonUSBusinessUseToReverseCharge =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
|
||||
|
||||
if (setNonUSBusinessUseToReverseCharge)
|
||||
if (setNonUSBusinessUseToReverseCharge && customer.HasBillingLocation())
|
||||
{
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
|
||||
}
|
||||
|
@ -648,6 +648,12 @@ public class SubscriberService(
|
||||
{
|
||||
await stripeAdapter.TaxIdCreateAsync(customer.Id,
|
||||
new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId });
|
||||
|
||||
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
|
||||
{
|
||||
await stripeAdapter.TaxIdCreateAsync(customer.Id,
|
||||
new TaxIdCreateOptions { Type = StripeConstants.TaxIdType.EUVAT, Value = $"ES{taxInformation.TaxId}" });
|
||||
}
|
||||
}
|
||||
catch (StripeException e)
|
||||
{
|
||||
|
@ -80,6 +80,15 @@ public class PreviewTaxAmountCommand(
|
||||
Value = taxInformation.TaxId
|
||||
}
|
||||
];
|
||||
|
||||
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
|
||||
{
|
||||
options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions
|
||||
{
|
||||
Type = StripeConstants.TaxIdType.EUVAT,
|
||||
Value = $"ES{parameters.TaxInformation.TaxId}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (planType.GetProductTier() == ProductTierType.Families)
|
||||
|
@ -182,6 +182,8 @@ public static class FeatureFlagKeys
|
||||
public const string EnablePMFlightRecorder = "enable-pm-flight-recorder";
|
||||
public const string MobileErrorReporting = "mobile-error-reporting";
|
||||
public const string AndroidChromeAutofill = "android-chrome-autofill";
|
||||
public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings";
|
||||
public const string AppIntents = "app-intents";
|
||||
|
||||
/* Platform Team */
|
||||
public const string PersistPopupView = "persist-popup-view";
|
||||
|
@ -1,9 +1,9 @@
|
||||
using Bit.Core.Entities;
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Tools.Entities;
|
||||
namespace Bit.Core.Dirt.Reports.Entities;
|
||||
|
||||
public class PasswordHealthReportApplication : ITableObject<Guid>, IRevisable
|
||||
{
|
||||
|
@ -1,4 +1,4 @@
|
||||
namespace Bit.Core.Tools.Models.Data;
|
||||
namespace Bit.Core.Dirt.Reports.Models.Data;
|
||||
|
||||
public class MemberAccessDetails
|
||||
{
|
||||
|
@ -1,11 +1,11 @@
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Dirt.Reports.Entities;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
using Bit.Core.Dirt.Reports.Repositories;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures;
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
||||
public class AddPasswordHealthReportApplicationCommand : IAddPasswordHealthReportApplicationCommand
|
||||
{
|
||||
|
@ -1,9 +1,9 @@
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
using Bit.Core.Dirt.Reports.Repositories;
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures;
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
||||
public class DropPasswordHealthReportApplicationCommand : IDropPasswordHealthReportApplicationCommand
|
||||
{
|
||||
|
@ -1,9 +1,9 @@
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
using Bit.Core.Dirt.Reports.Entities;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.Repositories;
|
||||
using Bit.Core.Exceptions;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures;
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
||||
public class GetPasswordHealthReportApplicationQuery : IGetPasswordHealthReportApplicationQuery
|
||||
{
|
||||
|
@ -1,7 +1,7 @@
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||
using Bit.Core.Dirt.Reports.Entities;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
|
||||
public interface IAddPasswordHealthReportApplicationCommand
|
||||
{
|
||||
|
@ -1,6 +1,6 @@
|
||||
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
|
||||
public interface IDropPasswordHealthReportApplicationCommand
|
||||
{
|
||||
|
@ -1,6 +1,6 @@
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Dirt.Reports.Entities;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
|
||||
public interface IGetPasswordHealthReportApplicationQuery
|
||||
{
|
||||
|
@ -2,21 +2,21 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||
using Bit.Core.Vault.Models.Data;
|
||||
using Bit.Core.Vault.Queries;
|
||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures;
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
||||
public class MemberAccessCipherDetailsQuery : IMemberAccessCipherDetailsQuery
|
||||
{
|
||||
|
@ -1,7 +1,7 @@
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.ReportFeatures.Requests;
|
||||
using Bit.Core.Dirt.Reports.Models.Data;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
|
||||
public interface IMemberAccessCipherDetailsQuery
|
||||
{
|
||||
|
@ -1,8 +1,8 @@
|
||||
using Bit.Core.Tools.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Tools.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
|
||||
using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Tools.ReportFeatures;
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures;
|
||||
|
||||
public static class ReportingServiceCollectionExtensions
|
||||
{
|
||||
|
@ -1,4 +1,4 @@
|
||||
namespace Bit.Core.Tools.ReportFeatures.Requests;
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
public class AddPasswordHealthReportApplicationRequest
|
||||
{
|
||||
|
@ -1,4 +1,4 @@
|
||||
namespace Bit.Core.Tools.ReportFeatures.Requests;
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
public class DropPasswordHealthReportApplicationRequest
|
||||
{
|
||||
|
@ -1,4 +1,4 @@
|
||||
namespace Bit.Core.Tools.ReportFeatures.Requests;
|
||||
namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests;
|
||||
|
||||
public class MemberAccessCipherDetailsRequest
|
||||
{
|
||||
|
@ -1,7 +1,7 @@
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Dirt.Reports.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Core.Tools.Repositories;
|
||||
namespace Bit.Core.Dirt.Reports.Repositories;
|
||||
|
||||
public interface IPasswordHealthReportApplicationRepository : IRepository<PasswordHealthReportApplication, Guid>
|
||||
{
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
#nullable enable
|
||||
@ -14,6 +15,8 @@ public class Collection : ITableObject<Guid>
|
||||
public string? ExternalId { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
|
||||
public CollectionType Type { get; set; } = CollectionType.SharedCollection;
|
||||
public string? DefaultUserCollectionEmail { get; set; }
|
||||
|
||||
public void SetNewId()
|
||||
{
|
||||
|
7
src/Core/Enums/CollectionType.cs
Normal file
7
src/Core/Enums/CollectionType.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.Enums;
|
||||
|
||||
public enum CollectionType
|
||||
{
|
||||
SharedCollection = 0,
|
||||
DefaultUserCollection = 1,
|
||||
}
|
@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace Bit.Core.Exceptions;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class BadRequestException : Exception
|
||||
{
|
||||
public BadRequestException() : base()
|
||||
@ -41,5 +43,5 @@ public class BadRequestException : Exception
|
||||
}
|
||||
}
|
||||
|
||||
public ModelStateDictionary ModelState { get; set; }
|
||||
public ModelStateDictionary? ModelState { get; set; }
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
namespace Bit.Core.Exceptions;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class ConflictException : Exception
|
||||
{
|
||||
public ConflictException() : base("Conflict.") { }
|
||||
|
@ -1,5 +1,7 @@
|
||||
namespace Bit.Core.Exceptions;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class DnsQueryException : Exception
|
||||
{
|
||||
public DnsQueryException(string message)
|
||||
|
@ -1,5 +1,7 @@
|
||||
namespace Bit.Core.Exceptions;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class DomainClaimedException : Exception
|
||||
{
|
||||
public DomainClaimedException()
|
||||
|
@ -1,5 +1,7 @@
|
||||
namespace Bit.Core.Exceptions;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class DomainVerifiedException : Exception
|
||||
{
|
||||
public DomainVerifiedException()
|
||||
|
@ -1,5 +1,7 @@
|
||||
namespace Bit.Core.Exceptions;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class DuplicateDomainException : Exception
|
||||
{
|
||||
public DuplicateDomainException()
|
||||
|
@ -1,5 +1,7 @@
|
||||
namespace Bit.Core.Exceptions;
|
||||
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// Exception to throw when a requested feature is not yet enabled/available for the requesting context.
|
||||
/// </summary>
|
||||
|
@ -1,8 +1,10 @@
|
||||
namespace Bit.Core.Exceptions;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class GatewayException : Exception
|
||||
{
|
||||
public GatewayException(string message, Exception innerException = null)
|
||||
public GatewayException(string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{ }
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user