mirror of
https://github.com/bitwarden/server
synced 2025-12-25 20:53:16 +00:00
Add CQRS and caching support for OrganizationIntegrations (#6689)
* Add CQRS and caching support for OrganizationIntegrations * Use primary constructor for Delete command, per Claude suggestion * Fix namespace * Add XMLDoc for new commands / queries * Remove unnecessary extra call to AddExtendedCache in Startup (call in EventIntegrationsServiceCollectionExtensions handles this instead) * Alter strategy to use one cache / database call to retrieve all configurations for an event (including wildcards) * Updated README documentation to reflect updated Caching doc and updated CQRS approach
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -12,7 +12,10 @@ namespace Bit.Api.AdminConsole.Controllers;
|
||||
[Authorize("Application")]
|
||||
public class OrganizationIntegrationController(
|
||||
ICurrentContext currentContext,
|
||||
IOrganizationIntegrationRepository integrationRepository) : Controller
|
||||
ICreateOrganizationIntegrationCommand createCommand,
|
||||
IUpdateOrganizationIntegrationCommand updateCommand,
|
||||
IDeleteOrganizationIntegrationCommand deleteCommand,
|
||||
IGetOrganizationIntegrationsQuery getQuery) : Controller
|
||||
{
|
||||
[HttpGet("")]
|
||||
public async Task<List<OrganizationIntegrationResponseModel>> GetAsync(Guid organizationId)
|
||||
@@ -22,7 +25,7 @@ public class OrganizationIntegrationController(
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
|
||||
var integrations = await getQuery.GetManyByOrganizationAsync(organizationId);
|
||||
return integrations
|
||||
.Select(integration => new OrganizationIntegrationResponseModel(integration))
|
||||
.ToList();
|
||||
@@ -36,8 +39,10 @@ public class OrganizationIntegrationController(
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var integration = await integrationRepository.CreateAsync(model.ToOrganizationIntegration(organizationId));
|
||||
return new OrganizationIntegrationResponseModel(integration);
|
||||
var integration = model.ToOrganizationIntegration(organizationId);
|
||||
var created = await createCommand.CreateAsync(integration);
|
||||
|
||||
return new OrganizationIntegrationResponseModel(created);
|
||||
}
|
||||
|
||||
[HttpPut("{integrationId:guid}")]
|
||||
@@ -48,14 +53,10 @@ public class OrganizationIntegrationController(
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var integration = await integrationRepository.GetByIdAsync(integrationId);
|
||||
if (integration is null || integration.OrganizationId != organizationId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
var integration = model.ToOrganizationIntegration(organizationId);
|
||||
var updated = await updateCommand.UpdateAsync(organizationId, integrationId, integration);
|
||||
|
||||
await integrationRepository.ReplaceAsync(model.ToOrganizationIntegration(integration));
|
||||
return new OrganizationIntegrationResponseModel(integration);
|
||||
return new OrganizationIntegrationResponseModel(updated);
|
||||
}
|
||||
|
||||
[HttpDelete("{integrationId:guid}")]
|
||||
@@ -66,13 +67,7 @@ public class OrganizationIntegrationController(
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var integration = await integrationRepository.GetByIdAsync(integrationId);
|
||||
if (integration is null || integration.OrganizationId != organizationId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await integrationRepository.DeleteAsync(integration);
|
||||
await deleteCommand.DeleteAsync(organizationId, integrationId);
|
||||
}
|
||||
|
||||
[HttpPost("{integrationId:guid}/delete")]
|
||||
|
||||
@@ -226,7 +226,8 @@ public class Startup
|
||||
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
|
||||
}
|
||||
|
||||
// Add Slack / Teams Services for OAuth API requests - if configured
|
||||
// Add Event Integrations services
|
||||
services.AddEventIntegrationsCommandsQueries(globalSettings);
|
||||
services.AddSlackService(globalSettings);
|
||||
services.AddTeamsService(globalSettings);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
public static class EventIntegrationsServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds all event integrations commands, queries, and required cache infrastructure.
|
||||
/// This method is idempotent and can be called multiple times safely.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddEventIntegrationsCommandsQueries(
|
||||
this IServiceCollection services,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
// Ensure cache is registered first - commands depend on this keyed cache.
|
||||
// This is idempotent for the same named cache, so it's safe to call.
|
||||
services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings);
|
||||
|
||||
// Add all commands/queries
|
||||
services.AddOrganizationIntegrationCommandsQueries();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
internal static IServiceCollection AddOrganizationIntegrationCommandsQueries(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddScoped<ICreateOrganizationIntegrationCommand, CreateOrganizationIntegrationCommand>();
|
||||
services.TryAddScoped<IUpdateOrganizationIntegrationCommand, UpdateOrganizationIntegrationCommand>();
|
||||
services.TryAddScoped<IDeleteOrganizationIntegrationCommand, DeleteOrganizationIntegrationCommand>();
|
||||
services.TryAddScoped<IGetOrganizationIntegrationsQuery, GetOrganizationIntegrationsQuery>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZiggyCreatures.Caching.Fusion;
|
||||
|
||||
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
|
||||
|
||||
/// <summary>
|
||||
/// Command implementation for creating organization integrations with cache invalidation support.
|
||||
/// </summary>
|
||||
public class CreateOrganizationIntegrationCommand(
|
||||
IOrganizationIntegrationRepository integrationRepository,
|
||||
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)]
|
||||
IFusionCache cache)
|
||||
: ICreateOrganizationIntegrationCommand
|
||||
{
|
||||
public async Task<OrganizationIntegration> CreateAsync(OrganizationIntegration integration)
|
||||
{
|
||||
var existingIntegrations = await integrationRepository
|
||||
.GetManyByOrganizationAsync(integration.OrganizationId);
|
||||
if (existingIntegrations.Any(i => i.Type == integration.Type))
|
||||
{
|
||||
throw new BadRequestException("An integration of this type already exists for this organization.");
|
||||
}
|
||||
|
||||
var created = await integrationRepository.CreateAsync(integration);
|
||||
await cache.RemoveByTagAsync(
|
||||
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
|
||||
organizationId: integration.OrganizationId,
|
||||
integrationType: integration.Type
|
||||
));
|
||||
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZiggyCreatures.Caching.Fusion;
|
||||
|
||||
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
|
||||
|
||||
/// <summary>
|
||||
/// Command implementation for deleting organization integrations with cache invalidation support.
|
||||
/// </summary>
|
||||
public class DeleteOrganizationIntegrationCommand(
|
||||
IOrganizationIntegrationRepository integrationRepository,
|
||||
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache)
|
||||
: IDeleteOrganizationIntegrationCommand
|
||||
{
|
||||
public async Task DeleteAsync(Guid organizationId, Guid integrationId)
|
||||
{
|
||||
var integration = await integrationRepository.GetByIdAsync(integrationId);
|
||||
if (integration is null || integration.OrganizationId != organizationId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await integrationRepository.DeleteAsync(integration);
|
||||
await cache.RemoveByTagAsync(
|
||||
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
|
||||
organizationId: organizationId,
|
||||
integrationType: integration.Type
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
|
||||
|
||||
/// <summary>
|
||||
/// Query implementation for retrieving organization integrations.
|
||||
/// </summary>
|
||||
public class GetOrganizationIntegrationsQuery(IOrganizationIntegrationRepository integrationRepository)
|
||||
: IGetOrganizationIntegrationsQuery
|
||||
{
|
||||
public async Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId)
|
||||
{
|
||||
var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
|
||||
return integrations.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Command interface for creating an OrganizationIntegration.
|
||||
/// </summary>
|
||||
public interface ICreateOrganizationIntegrationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new organization integration.
|
||||
/// </summary>
|
||||
/// <param name="integration">The OrganizationIntegration to create.</param>
|
||||
/// <returns>The created OrganizationIntegration.</returns>
|
||||
/// <exception cref="Exceptions.BadRequestException">Thrown when an integration
|
||||
/// of the same type already exists for the organization.</exception>
|
||||
Task<OrganizationIntegration> CreateAsync(OrganizationIntegration integration);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Command interface for deleting organization integrations.
|
||||
/// </summary>
|
||||
public interface IDeleteOrganizationIntegrationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Deletes an organization integration.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The unique identifier of the organization.</param>
|
||||
/// <param name="integrationId">The unique identifier of the integration to delete.</param>
|
||||
/// <exception cref="Exceptions.NotFoundException">Thrown when the integration does not exist
|
||||
/// or does not belong to the specified organization.</exception>
|
||||
Task DeleteAsync(Guid organizationId, Guid integrationId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Query interface for retrieving organization integrations.
|
||||
/// </summary>
|
||||
public interface IGetOrganizationIntegrationsQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves all organization integrations for a specific organization.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The unique identifier of the organization.</param>
|
||||
/// <returns>A list of organization integrations associated with the organization.</returns>
|
||||
Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Command interface for updating organization integrations.
|
||||
/// </summary>
|
||||
public interface IUpdateOrganizationIntegrationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Updates an existing organization integration.
|
||||
/// </summary>
|
||||
/// <param name="organizationId">The unique identifier of the organization.</param>
|
||||
/// <param name="integrationId">The unique identifier of the integration to update.</param>
|
||||
/// <param name="updatedIntegration">The updated organization integration data.</param>
|
||||
/// <returns>The updated organization integration.</returns>
|
||||
/// <exception cref="Exceptions.NotFoundException">Thrown when the integration does not exist,
|
||||
/// does not belong to the specified organization, or the integration type does not match.</exception>
|
||||
Task<OrganizationIntegration> UpdateAsync(Guid organizationId, Guid integrationId, OrganizationIntegration updatedIntegration);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZiggyCreatures.Caching.Fusion;
|
||||
|
||||
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
|
||||
|
||||
/// <summary>
|
||||
/// Command implementation for updating organization integrations with cache invalidation support.
|
||||
/// </summary>
|
||||
public class UpdateOrganizationIntegrationCommand(
|
||||
IOrganizationIntegrationRepository integrationRepository,
|
||||
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)]
|
||||
IFusionCache cache)
|
||||
: IUpdateOrganizationIntegrationCommand
|
||||
{
|
||||
public async Task<OrganizationIntegration> UpdateAsync(
|
||||
Guid organizationId,
|
||||
Guid integrationId,
|
||||
OrganizationIntegration updatedIntegration)
|
||||
{
|
||||
var integration = await integrationRepository.GetByIdAsync(integrationId);
|
||||
if (integration is null ||
|
||||
integration.OrganizationId != organizationId ||
|
||||
integration.Type != updatedIntegration.Type)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
updatedIntegration.Id = integration.Id;
|
||||
updatedIntegration.OrganizationId = integration.OrganizationId;
|
||||
updatedIntegration.CreationDate = integration.CreationDate;
|
||||
await integrationRepository.ReplaceAsync(updatedIntegration);
|
||||
await cache.RemoveByTagAsync(
|
||||
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
|
||||
organizationId: organizationId,
|
||||
integrationType: integration.Type
|
||||
));
|
||||
|
||||
return updatedIntegration;
|
||||
}
|
||||
}
|
||||
@@ -296,7 +296,7 @@ graph TD
|
||||
## Caching
|
||||
|
||||
To reduce database load and improve performance, event integrations uses its own named extended cache (see
|
||||
the [README in Utilities](https://github.com/bitwarden/server/blob/main/src/Core/Utilities/README.md#extended-cache)
|
||||
[CACHING in Utilities](https://github.com/bitwarden/server/blob/main/src/Core/Utilities/CACHING.md)
|
||||
for more information). Without caching, for instance, each incoming `EventMessage` would trigger a database
|
||||
query to retrieve the relevant `OrganizationIntegrationConfigurationDetails`.
|
||||
|
||||
@@ -335,7 +335,8 @@ rather than using a string literal (i.e. "EventIntegrations") in code.
|
||||
- There are two places in the code that are both aware of the tagging functionality
|
||||
- The `EventIntegrationHandler` must use the tag when fetching relevant configuration details. This tells the cache
|
||||
to store the entry with the tag when it successfully loads from the repository.
|
||||
- The `OrganizationIntegrationController` needs to use the tag to remove all the tagged entries when and admin
|
||||
- The `CreateOrganizationIntegrationCommand`, `UpdateOrganizationIntegrationCommand`, and
|
||||
`DeleteOrganizationIntegrationCommand` commands need to use the tag to remove all the tagged entries when an admin
|
||||
creates, updates, or deletes an integration.
|
||||
- To ensure both places are synchronized on how to tag entries, they both use
|
||||
`EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration` to build the tag.
|
||||
|
||||
Reference in New Issue
Block a user