From 3d06668497af7eaa662972a982686a090f4b959e Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Mon, 15 Dec 2025 10:30:22 -0600 Subject: [PATCH] [PM-28450] Single integration service (#17925) --- .../configuration/datadog-configuration.ts | 11 +- .../models/configuration/hec-configuration.ts | 11 +- .../configuration/webhook-configuration.ts | 9 +- .../models/integration-builder.ts | 94 +++ .../datadog-template.ts | 11 +- .../configuration-template/hec-template.ts | 11 +- .../webhook-template.ts | 9 +- .../organization-integration-configuration.ts | 12 +- .../organization-integration-service-type.ts | 6 +- .../models/organization-integration.ts | 16 +- ...g-organization-integration-service.spec.ts | 184 ----- ...atadog-organization-integration-service.ts | 350 ---------- ...c-organization-integration-service.spec.ts | 201 ------ .../hec-organization-integration-service.ts | 353 ---------- ...ganization-integration-api.service.spec.ts | 6 +- .../organization-integration-service.spec.ts | 633 ++++++++++++++++++ .../organization-integration-service.ts | 313 +++++++++ .../integration-card.component.spec.ts | 109 +-- .../integration-card.component.ts | 75 ++- .../integration-grid.component.spec.ts | 9 +- .../integrations.component.html | 137 ++-- .../integrations.component.ts | 54 +- .../organization-integrations.module.ts | 12 +- .../integrations.component.spec.ts | 9 +- .../integrations/integrations.module.ts | 12 +- 25 files changed, 1305 insertions(+), 1342 deletions(-) create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.spec.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.spec.ts delete mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.spec.ts create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.ts diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/datadog-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/datadog-configuration.ts index e788ebba7f2..51217a85877 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/datadog-configuration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/datadog-configuration.ts @@ -1,14 +1,15 @@ -import { OrganizationIntegrationServiceType } from "../organization-integration-service-type"; +import { OrgIntegrationConfiguration } from "../integration-builder"; +import { OrganizationIntegrationServiceName } from "../organization-integration-service-type"; -export class DatadogConfiguration { +export class DatadogConfiguration implements OrgIntegrationConfiguration { uri: string; apiKey: string; - service: OrganizationIntegrationServiceType; + service: OrganizationIntegrationServiceName; - constructor(uri: string, apiKey: string, service: string) { + constructor(uri: string, apiKey: string, service: OrganizationIntegrationServiceName) { this.uri = uri; this.apiKey = apiKey; - this.service = service as OrganizationIntegrationServiceType; + this.service = service; } toString(): string { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts index cdb7a5f265a..d7e0cec1840 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts @@ -1,15 +1,16 @@ -import { OrganizationIntegrationServiceType } from "../organization-integration-service-type"; +import { OrgIntegrationConfiguration } from "../integration-builder"; +import { OrganizationIntegrationServiceName } from "../organization-integration-service-type"; -export class HecConfiguration { +export class HecConfiguration implements OrgIntegrationConfiguration { uri: string; scheme = "Bearer"; token: string; - service: OrganizationIntegrationServiceType; + service: OrganizationIntegrationServiceName; - constructor(uri: string, token: string, service: string) { + constructor(uri: string, token: string, service: OrganizationIntegrationServiceName) { this.uri = uri; this.token = token; - this.service = service as OrganizationIntegrationServiceType; + this.service = service; } toString(): string { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/webhook-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/webhook-configuration.ts index a4dca7378ba..2b9ed6f7bda 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/webhook-configuration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/webhook-configuration.ts @@ -1,11 +1,16 @@ +import { OrgIntegrationConfiguration } from "../integration-builder"; +import { OrganizationIntegrationServiceName } from "../organization-integration-service-type"; + // Added to reflect how future webhook integrations could be structured within the OrganizationIntegration -export class WebhookConfiguration { +export class WebhookConfiguration implements OrgIntegrationConfiguration { propA: string; propB: string; + service: OrganizationIntegrationServiceName; - constructor(propA: string, propB: string) { + constructor(propA: string, propB: string, service: OrganizationIntegrationServiceName) { this.propA = propA; this.propB = propB; + this.service = service; } toString(): string { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts new file mode 100644 index 00000000000..ae790a67408 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts @@ -0,0 +1,94 @@ +import { DatadogConfiguration } from "./configuration/datadog-configuration"; +import { HecConfiguration } from "./configuration/hec-configuration"; +import { DatadogTemplate } from "./integration-configuration-config/configuration-template/datadog-template"; +import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template"; +import { OrganizationIntegrationServiceName } from "./organization-integration-service-type"; +import { OrganizationIntegrationType } from "./organization-integration-type"; + +/** + * Defines the structure for organization integration configuration + */ +export interface OrgIntegrationConfiguration { + service: OrganizationIntegrationServiceName; + toString(): string; +} + +/** + * Defines the structure for organization integration template + */ +export interface OrgIntegrationTemplate { + service: OrganizationIntegrationServiceName; + toString(): string; +} + +/** + * Builder class for creating organization integration configurations and templates + */ +export class OrgIntegrationBuilder { + static buildHecConfiguration( + uri: string, + token: string, + service: OrganizationIntegrationServiceName, + ): OrgIntegrationConfiguration { + return new HecConfiguration(uri, token, service); + } + + static buildHecTemplate( + index: string, + service: OrganizationIntegrationServiceName, + ): OrgIntegrationTemplate { + return new HecTemplate(index, service); + } + + static buildDataDogConfiguration(uri: string, apiKey: string): OrgIntegrationConfiguration { + return new DatadogConfiguration(uri, apiKey, OrganizationIntegrationServiceName.Datadog); + } + + static buildDataDogTemplate(service: OrganizationIntegrationServiceName): OrgIntegrationTemplate { + return new DatadogTemplate(service); + } + + static buildConfiguration( + type: OrganizationIntegrationType, + configuration: string, + ): OrgIntegrationConfiguration { + switch (type) { + case OrganizationIntegrationType.Hec: { + const hecConfig = this.convertToJson(configuration); + return this.buildHecConfiguration(hecConfig.uri, hecConfig.token, hecConfig.service); + } + case OrganizationIntegrationType.Datadog: { + const datadogConfig = this.convertToJson(configuration); + return this.buildDataDogConfiguration(datadogConfig.uri, datadogConfig.apiKey); + } + default: + throw new Error(`Unsupported integration type: ${type}`); + } + } + + static buildTemplate( + type: OrganizationIntegrationType, + template: string, + ): OrgIntegrationTemplate { + switch (type) { + case OrganizationIntegrationType.Hec: { + const hecTemplate = this.convertToJson(template); + return this.buildHecTemplate(hecTemplate.index, hecTemplate.service); + } + case OrganizationIntegrationType.Datadog: { + const datadogTemplate = this.convertToJson(template); + return this.buildDataDogTemplate(datadogTemplate.service); + } + default: + throw new Error(`Unsupported integration type: ${type}`); + } + } + + private static convertToJson(jsonString?: string): T { + try { + return JSON.parse(jsonString || "{}") as T; + } catch { + throw new Error("Invalid integration configuration: JSON parse error"); + } + } +} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/datadog-template.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/datadog-template.ts index 9aa6e34f478..d8e168aacbe 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/datadog-template.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/datadog-template.ts @@ -1,14 +1,15 @@ -import { OrganizationIntegrationServiceType } from "../../organization-integration-service-type"; +import { OrgIntegrationTemplate } from "../../integration-builder"; +import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type"; -export class DatadogTemplate { +export class DatadogTemplate implements OrgIntegrationTemplate { source_type_name = "Bitwarden"; title: string = "#Title#"; text: string = "ActingUser: #ActingUserId#\nUser: #UserId#\nEvent: #Type#\nOrganization: #OrganizationId#\nPolicyId: #PolicyId#\nIpAddress: #IpAddress#\nDomainName: #DomainName#\nCipherId: #CipherId#\n"; - service: OrganizationIntegrationServiceType; + service: OrganizationIntegrationServiceName; - constructor(service: string) { - this.service = service as OrganizationIntegrationServiceType; + constructor(service: OrganizationIntegrationServiceName) { + this.service = service; } toString(): string { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts index 7a841697fde..e1b474d0e77 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts @@ -1,14 +1,15 @@ -import { OrganizationIntegrationServiceType } from "../../organization-integration-service-type"; +import { OrgIntegrationTemplate } from "../../integration-builder"; +import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type"; -export class HecTemplate { +export class HecTemplate implements OrgIntegrationTemplate { event = "#EventMessage#"; source = "Bitwarden"; index: string; - service: OrganizationIntegrationServiceType; + service: OrganizationIntegrationServiceName; - constructor(index: string, service: string) { + constructor(index: string, service: OrganizationIntegrationServiceName) { this.index = index; - this.service = service as OrganizationIntegrationServiceType; + this.service = service; } toString(): string { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/webhook-template.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/webhook-template.ts index 7c51e98282b..fb482d1f367 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/webhook-template.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/webhook-template.ts @@ -1,9 +1,14 @@ +import { OrgIntegrationTemplate } from "../../integration-builder"; +import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type"; + // Added to reflect how future webhook integrations could be structured within the OrganizationIntegration -export class WebhookTemplate { +export class WebhookTemplate implements OrgIntegrationTemplate { + service: OrganizationIntegrationServiceName; propA: string; propB: string; - constructor(propA: string, propB: string) { + constructor(service: OrganizationIntegrationServiceName, propA: string, propB: string) { + this.service = service; this.propA = propA; this.propB = propB; } diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts index 0209460b630..5271dcd18da 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts @@ -4,31 +4,25 @@ import { OrganizationIntegrationId, } from "@bitwarden/common/types/guid"; -import { DatadogTemplate } from "./integration-configuration-config/configuration-template/datadog-template"; -import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template"; -import { WebhookTemplate } from "./integration-configuration-config/configuration-template/webhook-template"; -import { WebhookIntegrationConfigurationConfig } from "./integration-configuration-config/webhook-integration-configuration-config"; +import { OrgIntegrationTemplate } from "./integration-builder"; export class OrganizationIntegrationConfiguration { id: OrganizationIntegrationConfigurationId; integrationId: OrganizationIntegrationId; eventType?: EventType | null; - configuration?: WebhookIntegrationConfigurationConfig | null; filters?: string; - template?: HecTemplate | WebhookTemplate | DatadogTemplate | null; + template?: OrgIntegrationTemplate | null; constructor( id: OrganizationIntegrationConfigurationId, integrationId: OrganizationIntegrationId, eventType?: EventType | null, - configuration?: WebhookIntegrationConfigurationConfig | null, filters?: string, - template?: HecTemplate | WebhookTemplate | DatadogTemplate | null, + template?: OrgIntegrationTemplate | null, ) { this.id = id; this.integrationId = integrationId; this.eventType = eventType; - this.configuration = configuration; this.filters = filters; this.template = template; } diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts index e9e93adc0ff..9634ad7249a 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts @@ -1,7 +1,7 @@ -export const OrganizationIntegrationServiceType = Object.freeze({ +export const OrganizationIntegrationServiceName = Object.freeze({ CrowdStrike: "CrowdStrike", Datadog: "Datadog", } as const); -export type OrganizationIntegrationServiceType = - (typeof OrganizationIntegrationServiceType)[keyof typeof OrganizationIntegrationServiceType]; +export type OrganizationIntegrationServiceName = + (typeof OrganizationIntegrationServiceName)[keyof typeof OrganizationIntegrationServiceName]; diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts index d32c92a460a..84b633a207c 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts @@ -1,29 +1,27 @@ import { OrganizationIntegrationId } from "@bitwarden/common/types/guid"; -import { DatadogConfiguration } from "./configuration/datadog-configuration"; -import { HecConfiguration } from "./configuration/hec-configuration"; -import { WebhookConfiguration } from "./configuration/webhook-configuration"; +import { OrgIntegrationConfiguration } from "./integration-builder"; import { OrganizationIntegrationConfiguration } from "./organization-integration-configuration"; -import { OrganizationIntegrationServiceType } from "./organization-integration-service-type"; +import { OrganizationIntegrationServiceName } from "./organization-integration-service-type"; import { OrganizationIntegrationType } from "./organization-integration-type"; export class OrganizationIntegration { id: OrganizationIntegrationId; type: OrganizationIntegrationType; - serviceType: OrganizationIntegrationServiceType; - configuration: HecConfiguration | WebhookConfiguration | DatadogConfiguration | null; + serviceName: OrganizationIntegrationServiceName; + configuration: OrgIntegrationConfiguration | null; integrationConfiguration: OrganizationIntegrationConfiguration[] = []; constructor( id: OrganizationIntegrationId, type: OrganizationIntegrationType, - serviceType: OrganizationIntegrationServiceType, - configuration: HecConfiguration | WebhookConfiguration | DatadogConfiguration | null, + serviceName: OrganizationIntegrationServiceName, + configuration: OrgIntegrationConfiguration | null, integrationConfiguration: OrganizationIntegrationConfiguration[] = [], ) { this.id = id; this.type = type; - this.serviceType = serviceType; + this.serviceName = serviceName; this.configuration = configuration; this.integrationConfiguration = integrationConfiguration; } diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.spec.ts deleted file mode 100644 index 0545f95cb83..00000000000 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.spec.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { mock } from "jest-mock-extended"; -import { firstValueFrom } from "rxjs"; - -import { - OrganizationId, - OrganizationIntegrationConfigurationId, - OrganizationIntegrationId, -} from "@bitwarden/common/types/guid"; - -import { DatadogConfiguration } from "../models/configuration/datadog-configuration"; -import { DatadogTemplate } from "../models/integration-configuration-config/configuration-template/datadog-template"; -import { OrganizationIntegration } from "../models/organization-integration"; -import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration"; -import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response"; -import { OrganizationIntegrationResponse } from "../models/organization-integration-response"; -import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type"; -import { OrganizationIntegrationType } from "../models/organization-integration-type"; - -import { DatadogOrganizationIntegrationService } from "./datadog-organization-integration-service"; -import { OrganizationIntegrationApiService } from "./organization-integration-api.service"; -import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service"; - -describe("DatadogOrganizationIntegrationService", () => { - let service: DatadogOrganizationIntegrationService; - const mockIntegrationApiService = mock(); - const mockIntegrationConfigurationApiService = - mock(); - const organizationId = "org-1" as OrganizationId; - const integrationId = "int-1" as OrganizationIntegrationId; - const configId = "conf-1" as OrganizationIntegrationConfigurationId; - const serviceType = OrganizationIntegrationServiceType.CrowdStrike; - const url = "https://example.com"; - const apiKey = "token"; - - beforeEach(() => { - service = new DatadogOrganizationIntegrationService( - mockIntegrationApiService, - mockIntegrationConfigurationApiService, - ); - - jest.resetAllMocks(); - }); - - it("should set organization integrations", (done) => { - mockIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([]); - service.setOrganizationIntegrations(organizationId); - const subscription = service.integrations$.subscribe((integrations) => { - expect(integrations).toEqual([]); - subscription.unsubscribe(); - done(); - }); - }); - - it("should save a new Datadog integration", async () => { - service.setOrganizationIntegrations(organizationId); - - const integrationResponse = { - id: integrationId, - type: OrganizationIntegrationType.Datadog, - configuration: JSON.stringify({ url, apiKey, service: serviceType }), - } as OrganizationIntegrationResponse; - - const configResponse = { - id: configId, - template: JSON.stringify({ service: serviceType }), - } as OrganizationIntegrationConfigurationResponse; - - mockIntegrationApiService.createOrganizationIntegration.mockResolvedValue(integrationResponse); - mockIntegrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockResolvedValue( - configResponse, - ); - - await service.saveDatadog(organizationId, serviceType, url, apiKey); - - const integrations = await firstValueFrom(service.integrations$); - expect(integrations.length).toBe(1); - expect(integrations[0].id).toBe(integrationId); - expect(integrations[0].serviceType).toBe(serviceType); - }); - - it("should throw error on organization ID mismatch in saveDatadog", async () => { - service.setOrganizationIntegrations("other-org" as OrganizationId); - await expect(service.saveDatadog(organizationId, serviceType, url, apiKey)).rejects.toThrow( - Error("Organization ID mismatch"), - ); - }); - - it("should update an existing Datadog integration", async () => { - service.setOrganizationIntegrations(organizationId); - - const integrationResponse = { - id: integrationId, - type: OrganizationIntegrationType.Datadog, - configuration: JSON.stringify({ url, apiKey, service: serviceType }), - } as OrganizationIntegrationResponse; - - const configResponse = { - id: configId, - template: JSON.stringify({ service: serviceType }), - } as OrganizationIntegrationConfigurationResponse; - - mockIntegrationApiService.updateOrganizationIntegration.mockResolvedValue(integrationResponse); - mockIntegrationConfigurationApiService.updateOrganizationIntegrationConfiguration.mockResolvedValue( - configResponse, - ); - - await service.updateDatadog(organizationId, integrationId, configId, serviceType, url, apiKey); - - const integrations = await firstValueFrom(service.integrations$); - expect(integrations.length).toBe(1); - expect(integrations[0].id).toBe(integrationId); - }); - - it("should throw error on organization ID mismatch in updateDatadog", async () => { - service.setOrganizationIntegrations("other-org" as OrganizationId); - await expect( - service.updateDatadog(organizationId, integrationId, configId, serviceType, url, apiKey), - ).rejects.toThrow(Error("Organization ID mismatch")); - }); - - it("should get integration by id", async () => { - service["_integrations$"].next([ - new OrganizationIntegration( - integrationId, - OrganizationIntegrationType.Datadog, - serviceType, - {} as DatadogConfiguration, - [], - ), - ]); - const integration = await service.getIntegrationById(integrationId); - expect(integration).not.toBeNull(); - expect(integration!.id).toBe(integrationId); - }); - - it("should get integration by service type", async () => { - service["_integrations$"].next([ - new OrganizationIntegration( - integrationId, - OrganizationIntegrationType.Datadog, - serviceType, - {} as DatadogConfiguration, - [], - ), - ]); - const integration = await service.getIntegrationByServiceType(serviceType); - expect(integration).not.toBeNull(); - expect(integration!.serviceType).toBe(serviceType); - }); - - it("should get integration configurations", async () => { - const config = new OrganizationIntegrationConfiguration( - configId, - integrationId, - null, - null, - "", - {} as DatadogTemplate, - ); - - service["_integrations$"].next([ - new OrganizationIntegration( - integrationId, - OrganizationIntegrationType.Datadog, - serviceType, - {} as DatadogConfiguration, - [config], - ), - ]); - const configs = await service.getIntegrationConfigurations(integrationId); - expect(configs).not.toBeNull(); - expect(configs![0].id).toBe(configId); - }); - - it("convertToJson should parse valid JSON", () => { - const obj = service.convertToJson<{ a: number }>('{"a":1}'); - expect(obj).toEqual({ a: 1 }); - }); - - it("convertToJson should return null for invalid JSON", () => { - const obj = service.convertToJson<{ a: number }>("invalid"); - expect(obj).toBeNull(); - }); -}); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.ts deleted file mode 100644 index 1fd5e9f8c06..00000000000 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { BehaviorSubject, firstValueFrom, map, Subject, switchMap, takeUntil, zip } from "rxjs"; - -import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; -import { - OrganizationId, - OrganizationIntegrationId, - OrganizationIntegrationConfigurationId, -} from "@bitwarden/common/types/guid"; - -import { DatadogConfiguration } from "../models/configuration/datadog-configuration"; -import { DatadogTemplate } from "../models/integration-configuration-config/configuration-template/datadog-template"; -import { OrganizationIntegration } from "../models/organization-integration"; -import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration"; -import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request"; -import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response"; -import { OrganizationIntegrationRequest } from "../models/organization-integration-request"; -import { OrganizationIntegrationResponse } from "../models/organization-integration-response"; -import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type"; -import { OrganizationIntegrationType } from "../models/organization-integration-type"; - -import { OrganizationIntegrationApiService } from "./organization-integration-api.service"; -import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service"; - -export type DatadogModificationFailureReason = { - mustBeOwner: boolean; - success: boolean; -}; - -export class DatadogOrganizationIntegrationService { - private organizationId$ = new BehaviorSubject(null); - private _integrations$ = new BehaviorSubject([]); - private destroy$ = new Subject(); - - integrations$ = this._integrations$.asObservable(); - - private fetch$ = this.organizationId$ - .pipe( - switchMap(async (orgId) => { - if (orgId) { - const data$ = await this.setIntegrations(orgId); - return await firstValueFrom(data$); - } else { - return this._integrations$.getValue(); - } - }), - takeUntil(this.destroy$), - ) - .subscribe({ - next: (integrations) => { - this._integrations$.next(integrations); - }, - }); - - constructor( - private integrationApiService: OrganizationIntegrationApiService, - private integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService, - ) {} - - /** - * Sets the organization Id and will trigger the retrieval of the - * integrations for a given org. - * @param orgId - */ - setOrganizationIntegrations(orgId: OrganizationId) { - this.organizationId$.next(orgId); - } - - /** - * Saves a new organization integration and updates the integrations$ observable - * @param organizationId id of the organization - * @param service service type of the integration - * @param url url of the service - * @param apiKey api token - */ - async saveDatadog( - organizationId: OrganizationId, - service: OrganizationIntegrationServiceType, - url: string, - apiKey: string, - ): Promise { - if (organizationId != this.organizationId$.getValue()) { - throw new Error("Organization ID mismatch"); - } - - try { - const datadogConfig = new DatadogConfiguration(url, apiKey, service); - const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration( - organizationId, - new OrganizationIntegrationRequest( - OrganizationIntegrationType.Datadog, - datadogConfig.toString(), - ), - ); - - const newTemplate = new DatadogTemplate(service); - const newIntegrationConfigResponse = - await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration( - organizationId, - newIntegrationResponse.id, - new OrganizationIntegrationConfigurationRequest(null, null, null, newTemplate.toString()), - ); - - const newIntegration = this.mapResponsesToOrganizationIntegration( - newIntegrationResponse, - newIntegrationConfigResponse, - ); - if (newIntegration !== null) { - this._integrations$.next([...this._integrations$.getValue(), newIntegration]); - } - return { mustBeOwner: false, success: true }; - } catch (error) { - if (error instanceof ErrorResponse && error.statusCode === 404) { - return { mustBeOwner: true, success: false }; - } - throw error; - } - } - - /** - * Updates an existing organization integration and updates the integrations$ observable - * @param organizationId id of the organization - * @param OrganizationIntegrationId id of the organization integration - * @param OrganizationIntegrationConfigurationId id of the organization integration configuration - * @param service service type of the integration - * @param url url of the service - * @param apiKey api token - */ - async updateDatadog( - organizationId: OrganizationId, - OrganizationIntegrationId: OrganizationIntegrationId, - OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId, - service: OrganizationIntegrationServiceType, - url: string, - apiKey: string, - ): Promise { - if (organizationId != this.organizationId$.getValue()) { - throw new Error("Organization ID mismatch"); - } - - try { - const datadogConfig = new DatadogConfiguration(url, apiKey, service); - const updatedIntegrationResponse = - await this.integrationApiService.updateOrganizationIntegration( - organizationId, - OrganizationIntegrationId, - new OrganizationIntegrationRequest( - OrganizationIntegrationType.Datadog, - datadogConfig.toString(), - ), - ); - - const updatedTemplate = new DatadogTemplate(service); - const updatedIntegrationConfigResponse = - await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration( - organizationId, - OrganizationIntegrationId, - OrganizationIntegrationConfigurationId, - new OrganizationIntegrationConfigurationRequest( - null, - null, - null, - updatedTemplate.toString(), - ), - ); - - const updatedIntegration = this.mapResponsesToOrganizationIntegration( - updatedIntegrationResponse, - updatedIntegrationConfigResponse, - ); - - if (updatedIntegration !== null) { - this._integrations$.next([...this._integrations$.getValue(), updatedIntegration]); - } - return { mustBeOwner: false, success: true }; - } catch (error) { - if (error instanceof ErrorResponse && error.statusCode === 404) { - return { mustBeOwner: true, success: false }; - } - throw error; - } - } - - async deleteDatadog( - organizationId: OrganizationId, - OrganizationIntegrationId: OrganizationIntegrationId, - OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId, - ): Promise { - if (organizationId != this.organizationId$.getValue()) { - throw new Error("Organization ID mismatch"); - } - - try { - // delete the configuration first due to foreign key constraint - await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration( - organizationId, - OrganizationIntegrationId, - OrganizationIntegrationConfigurationId, - ); - - // delete the integration - await this.integrationApiService.deleteOrganizationIntegration( - organizationId, - OrganizationIntegrationId, - ); - - // update the local observable - const updatedIntegrations = this._integrations$ - .getValue() - .filter((i) => i.id !== OrganizationIntegrationId); - this._integrations$.next(updatedIntegrations); - - return { mustBeOwner: false, success: true }; - } catch (error) { - if (error instanceof ErrorResponse && error.statusCode === 404) { - return { mustBeOwner: true, success: false }; - } - throw error; - } - } - - /** - * Gets a OrganizationIntegration for an OrganizationIntegrationId - * @param integrationId id of the integration - * @returns OrganizationIntegration or null - */ - // TODO: Move to base class when another service integration type is implemented - async getIntegrationById( - integrationId: OrganizationIntegrationId, - ): Promise { - return await firstValueFrom( - this.integrations$.pipe( - map((integrations) => integrations.find((i) => i.id === integrationId) || null), - ), - ); - } - - /** - * Gets a OrganizationIntegration for a service type - * @param serviceType type of the service - * @returns OrganizationIntegration or null - */ - // TODO: Move to base class when another service integration type is implemented - async getIntegrationByServiceType( - serviceType: OrganizationIntegrationServiceType, - ): Promise { - return await firstValueFrom( - this.integrations$.pipe( - map((integrations) => integrations.find((i) => i.serviceType === serviceType) || null), - ), - ); - } - - /** - * Gets a OrganizationIntegrationConfigurations for an integration ID - * @param integrationId id of the integration - * @returns OrganizationIntegration array or null - */ - // TODO: Move to base class when another service integration type is implemented - async getIntegrationConfigurations( - integrationId: OrganizationIntegrationId, - ): Promise { - return await firstValueFrom( - this.integrations$.pipe( - map((integrations) => { - const integration = integrations.find((i) => i.id === integrationId); - return integration ? integration.integrationConfiguration : null; - }), - ), - ); - } - - // TODO: Move to data models to be more explicit for future services - private mapResponsesToOrganizationIntegration( - integrationResponse: OrganizationIntegrationResponse, - configurationResponse: OrganizationIntegrationConfigurationResponse, - ): OrganizationIntegration | null { - const datadogConfig = this.convertToJson( - integrationResponse.configuration, - ); - const template = this.convertToJson(configurationResponse.template); - - if (!datadogConfig || !template) { - return null; - } - - const integrationConfig = new OrganizationIntegrationConfiguration( - configurationResponse.id, - integrationResponse.id, - null, - null, - "", - template, - ); - - return new OrganizationIntegration( - integrationResponse.id, - integrationResponse.type, - datadogConfig.service, - datadogConfig, - [integrationConfig], - ); - } - - // Could possibly be moved to a base service. All services would then assume that the - // integration configuration would always be an array and this datadog specific service - // would just assume a single entry. - private setIntegrations(orgId: OrganizationId) { - const results$ = zip(this.integrationApiService.getOrganizationIntegrations(orgId)).pipe( - switchMap(([responses]) => { - const integrations: OrganizationIntegration[] = []; - const promises: Promise[] = []; - - responses.forEach((integration) => { - if (integration.type === OrganizationIntegrationType.Datadog) { - const promise = this.integrationConfigurationApiService - .getOrganizationIntegrationConfigurations(orgId, integration.id) - .then((response) => { - // datadog events will only have one OrganizationIntegrationConfiguration - const config = response[0]; - - const orgIntegration = this.mapResponsesToOrganizationIntegration( - integration, - config, - ); - - if (orgIntegration !== null) { - integrations.push(orgIntegration); - } - }); - promises.push(promise); - } - }); - return Promise.all(promises).then(() => { - return integrations; - }); - }), - ); - - return results$; - } - - // TODO: Move to base service when necessary - convertToJson(jsonString?: string): T | null { - try { - return JSON.parse(jsonString || "") as T; - } catch { - return null; - } - } -} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.spec.ts deleted file mode 100644 index 556078ea862..00000000000 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.spec.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { mock } from "jest-mock-extended"; -import { firstValueFrom } from "rxjs"; - -import { - OrganizationId, - OrganizationIntegrationConfigurationId, - OrganizationIntegrationId, -} from "@bitwarden/common/types/guid"; - -import { HecConfiguration } from "../models/configuration/hec-configuration"; -import { HecTemplate } from "../models/integration-configuration-config/configuration-template/hec-template"; -import { OrganizationIntegration } from "../models/organization-integration"; -import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration"; -import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response"; -import { OrganizationIntegrationResponse } from "../models/organization-integration-response"; -import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type"; -import { OrganizationIntegrationType } from "../models/organization-integration-type"; - -import { HecOrganizationIntegrationService } from "./hec-organization-integration-service"; -import { OrganizationIntegrationApiService } from "./organization-integration-api.service"; -import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service"; - -describe("HecOrganizationIntegrationService", () => { - let service: HecOrganizationIntegrationService; - const mockIntegrationApiService = mock(); - const mockIntegrationConfigurationApiService = - mock(); - const organizationId = "org-1" as OrganizationId; - const integrationId = "int-1" as OrganizationIntegrationId; - const configId = "conf-1" as OrganizationIntegrationConfigurationId; - const serviceType = OrganizationIntegrationServiceType.CrowdStrike; - const url = "https://example.com"; - const bearerToken = "token"; - const index = "main"; - - beforeEach(() => { - service = new HecOrganizationIntegrationService( - mockIntegrationApiService, - mockIntegrationConfigurationApiService, - ); - - jest.resetAllMocks(); - }); - - it("should set organization integrations", (done) => { - mockIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([]); - service.setOrganizationIntegrations(organizationId); - const subscription = service.integrations$.subscribe((integrations) => { - expect(integrations).toEqual([]); - subscription.unsubscribe(); - done(); - }); - }); - - it("should save a new Hec integration", async () => { - service.setOrganizationIntegrations(organizationId); - - const integrationResponse = { - id: integrationId, - type: OrganizationIntegrationType.Hec, - configuration: JSON.stringify({ url, bearerToken, service: serviceType }), - } as OrganizationIntegrationResponse; - - const configResponse = { - id: configId, - template: JSON.stringify({ index, service: serviceType }), - } as OrganizationIntegrationConfigurationResponse; - - mockIntegrationApiService.createOrganizationIntegration.mockResolvedValue(integrationResponse); - mockIntegrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockResolvedValue( - configResponse, - ); - - await service.saveHec(organizationId, serviceType, url, bearerToken, index); - - const integrations = await firstValueFrom(service.integrations$); - expect(integrations.length).toBe(1); - expect(integrations[0].id).toBe(integrationId); - expect(integrations[0].serviceType).toBe(serviceType); - }); - - it("should throw error on organization ID mismatch in saveHec", async () => { - service.setOrganizationIntegrations("other-org" as OrganizationId); - await expect( - service.saveHec(organizationId, serviceType, url, bearerToken, index), - ).rejects.toThrow(Error("Organization ID mismatch")); - }); - - it("should update an existing Hec integration", async () => { - service.setOrganizationIntegrations(organizationId); - - const integrationResponse = { - id: integrationId, - type: OrganizationIntegrationType.Hec, - configuration: JSON.stringify({ url, bearerToken, service: serviceType }), - } as OrganizationIntegrationResponse; - - const configResponse = { - id: configId, - template: JSON.stringify({ index, service: serviceType }), - } as OrganizationIntegrationConfigurationResponse; - - mockIntegrationApiService.updateOrganizationIntegration.mockResolvedValue(integrationResponse); - mockIntegrationConfigurationApiService.updateOrganizationIntegrationConfiguration.mockResolvedValue( - configResponse, - ); - - await service.updateHec( - organizationId, - integrationId, - configId, - serviceType, - url, - bearerToken, - index, - ); - - const integrations = await firstValueFrom(service.integrations$); - expect(integrations.length).toBe(1); - expect(integrations[0].id).toBe(integrationId); - }); - - it("should throw error on organization ID mismatch in updateHec", async () => { - service.setOrganizationIntegrations("other-org" as OrganizationId); - await expect( - service.updateHec( - organizationId, - integrationId, - configId, - serviceType, - url, - bearerToken, - index, - ), - ).rejects.toThrow(Error("Organization ID mismatch")); - }); - - it("should get integration by id", async () => { - service["_integrations$"].next([ - new OrganizationIntegration( - integrationId, - OrganizationIntegrationType.Hec, - serviceType, - {} as HecConfiguration, - [], - ), - ]); - const integration = await service.getIntegrationById(integrationId); - expect(integration).not.toBeNull(); - expect(integration!.id).toBe(integrationId); - }); - - it("should get integration by service type", async () => { - service["_integrations$"].next([ - new OrganizationIntegration( - integrationId, - OrganizationIntegrationType.Hec, - serviceType, - {} as HecConfiguration, - [], - ), - ]); - const integration = await service.getIntegrationByServiceType(serviceType); - expect(integration).not.toBeNull(); - expect(integration!.serviceType).toBe(serviceType); - }); - - it("should get integration configurations", async () => { - const config = new OrganizationIntegrationConfiguration( - configId, - integrationId, - null, - null, - "", - {} as HecTemplate, - ); - - service["_integrations$"].next([ - new OrganizationIntegration( - integrationId, - OrganizationIntegrationType.Hec, - serviceType, - {} as HecConfiguration, - [config], - ), - ]); - const configs = await service.getIntegrationConfigurations(integrationId); - expect(configs).not.toBeNull(); - expect(configs![0].id).toBe(configId); - }); - - it("convertToJson should parse valid JSON", () => { - const obj = service.convertToJson<{ a: number }>('{"a":1}'); - expect(obj).toEqual({ a: 1 }); - }); - - it("convertToJson should return null for invalid JSON", () => { - const obj = service.convertToJson<{ a: number }>("invalid"); - expect(obj).toBeNull(); - }); -}); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts deleted file mode 100644 index b83ea26e166..00000000000 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { BehaviorSubject, firstValueFrom, map, Subject, switchMap, takeUntil, zip } from "rxjs"; - -import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; -import { - OrganizationId, - OrganizationIntegrationId, - OrganizationIntegrationConfigurationId, -} from "@bitwarden/common/types/guid"; - -import { HecConfiguration } from "../models/configuration/hec-configuration"; -import { HecTemplate } from "../models/integration-configuration-config/configuration-template/hec-template"; -import { OrganizationIntegration } from "../models/organization-integration"; -import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration"; -import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request"; -import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response"; -import { OrganizationIntegrationRequest } from "../models/organization-integration-request"; -import { OrganizationIntegrationResponse } from "../models/organization-integration-response"; -import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type"; -import { OrganizationIntegrationType } from "../models/organization-integration-type"; - -import { OrganizationIntegrationApiService } from "./organization-integration-api.service"; -import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service"; - -export type HecModificationFailureReason = { - mustBeOwner: boolean; - success: boolean; -}; - -export class HecOrganizationIntegrationService { - private organizationId$ = new BehaviorSubject(null); - private _integrations$ = new BehaviorSubject([]); - private destroy$ = new Subject(); - - integrations$ = this._integrations$.asObservable(); - - private fetch$ = this.organizationId$ - .pipe( - switchMap(async (orgId) => { - if (orgId) { - const data$ = await this.setIntegrations(orgId); - return await firstValueFrom(data$); - } else { - return [] as OrganizationIntegration[]; - } - }), - takeUntil(this.destroy$), - ) - .subscribe({ - next: (integrations) => { - this._integrations$.next(integrations); - }, - }); - - constructor( - private integrationApiService: OrganizationIntegrationApiService, - private integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService, - ) {} - - /** - * Sets the organization Id and will trigger the retrieval of the - * integrations for a given org. - * @param orgId - */ - setOrganizationIntegrations(orgId: OrganizationId) { - if (orgId == this.organizationId$.getValue()) { - return; - } - this._integrations$.next([]); - this.organizationId$.next(orgId); - } - - /** - * Saves a new organization integration and updates the integrations$ observable - * @param organizationId id of the organization - * @param service service type of the integration - * @param url url of the service - * @param bearerToken api token - * @param index index in service - */ - async saveHec( - organizationId: OrganizationId, - service: OrganizationIntegrationServiceType, - url: string, - bearerToken: string, - index: string, - ): Promise { - if (organizationId != this.organizationId$.getValue()) { - throw new Error("Organization ID mismatch"); - } - - try { - const hecConfig = new HecConfiguration(url, bearerToken, service); - const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration( - organizationId, - new OrganizationIntegrationRequest(OrganizationIntegrationType.Hec, hecConfig.toString()), - ); - - const newTemplate = new HecTemplate(index, service); - const newIntegrationConfigResponse = - await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration( - organizationId, - newIntegrationResponse.id, - new OrganizationIntegrationConfigurationRequest(null, null, null, newTemplate.toString()), - ); - - const newIntegration = this.mapResponsesToOrganizationIntegration( - newIntegrationResponse, - newIntegrationConfigResponse, - ); - if (newIntegration !== null) { - this._integrations$.next([...this._integrations$.getValue(), newIntegration]); - } - return { mustBeOwner: false, success: true }; - } catch (error) { - if (error instanceof ErrorResponse && error.statusCode === 404) { - return { mustBeOwner: true, success: false }; - } - throw error; - } - } - - /** - * Updates an existing organization integration and updates the integrations$ observable - * @param organizationId id of the organization - * @param OrganizationIntegrationId id of the organization integration - * @param OrganizationIntegrationConfigurationId id of the organization integration configuration - * @param service service type of the integration - * @param url url of the service - * @param bearerToken api token - * @param index index in service - */ - async updateHec( - organizationId: OrganizationId, - OrganizationIntegrationId: OrganizationIntegrationId, - OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId, - service: OrganizationIntegrationServiceType, - url: string, - bearerToken: string, - index: string, - ): Promise { - if (organizationId != this.organizationId$.getValue()) { - throw new Error("Organization ID mismatch"); - } - - try { - const hecConfig = new HecConfiguration(url, bearerToken, service); - const updatedIntegrationResponse = - await this.integrationApiService.updateOrganizationIntegration( - organizationId, - OrganizationIntegrationId, - new OrganizationIntegrationRequest(OrganizationIntegrationType.Hec, hecConfig.toString()), - ); - - const updatedTemplate = new HecTemplate(index, service); - const updatedIntegrationConfigResponse = - await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration( - organizationId, - OrganizationIntegrationId, - OrganizationIntegrationConfigurationId, - new OrganizationIntegrationConfigurationRequest( - null, - null, - null, - updatedTemplate.toString(), - ), - ); - - const updatedIntegration = this.mapResponsesToOrganizationIntegration( - updatedIntegrationResponse, - updatedIntegrationConfigResponse, - ); - - if (updatedIntegration !== null) { - const unchangedIntegrations = this._integrations$ - .getValue() - .filter((i) => i.id !== OrganizationIntegrationId); - this._integrations$.next([...unchangedIntegrations, updatedIntegration]); - } - return { mustBeOwner: false, success: true }; - } catch (error) { - if (error instanceof ErrorResponse && error.statusCode === 404) { - return { mustBeOwner: true, success: false }; - } - throw error; - } - } - - async deleteHec( - organizationId: OrganizationId, - OrganizationIntegrationId: OrganizationIntegrationId, - OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId, - ): Promise { - if (organizationId != this.organizationId$.getValue()) { - throw new Error("Organization ID mismatch"); - } - - try { - // delete the configuration first due to foreign key constraint - await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration( - organizationId, - OrganizationIntegrationId, - OrganizationIntegrationConfigurationId, - ); - - // delete the integration - await this.integrationApiService.deleteOrganizationIntegration( - organizationId, - OrganizationIntegrationId, - ); - - // update the local observable - const updatedIntegrations = this._integrations$ - .getValue() - .filter((i) => i.id !== OrganizationIntegrationId); - this._integrations$.next(updatedIntegrations); - - return { mustBeOwner: false, success: true }; - } catch (error) { - if (error instanceof ErrorResponse && error.statusCode === 404) { - return { mustBeOwner: true, success: false }; - } - throw error; - } - } - - /** - * Gets a OrganizationIntegration for an OrganizationIntegrationId - * @param integrationId id of the integration - * @returns OrganizationIntegration or null - */ - // TODO: Move to base class when another service integration type is implemented - async getIntegrationById( - integrationId: OrganizationIntegrationId, - ): Promise { - return await firstValueFrom( - this.integrations$.pipe( - map((integrations) => integrations.find((i) => i.id === integrationId) || null), - ), - ); - } - - /** - * Gets a OrganizationIntegration for a service type - * @param serviceType type of the service - * @returns OrganizationIntegration or null - */ - // TODO: Move to base class when another service integration type is implemented - async getIntegrationByServiceType( - serviceType: OrganizationIntegrationServiceType, - ): Promise { - return await firstValueFrom( - this.integrations$.pipe( - map((integrations) => integrations.find((i) => i.serviceType === serviceType) || null), - ), - ); - } - - /** - * Gets a OrganizationIntegrationConfigurations for an integration ID - * @param integrationId id of the integration - * @returns OrganizationIntegration array or null - */ - // TODO: Move to base class when another service integration type is implemented - async getIntegrationConfigurations( - integrationId: OrganizationIntegrationId, - ): Promise { - return await firstValueFrom( - this.integrations$.pipe( - map((integrations) => { - const integration = integrations.find((i) => i.id === integrationId); - return integration ? integration.integrationConfiguration : null; - }), - ), - ); - } - - // TODO: Move to data models to be more explicit for future services - private mapResponsesToOrganizationIntegration( - integrationResponse: OrganizationIntegrationResponse, - configurationResponse: OrganizationIntegrationConfigurationResponse, - ): OrganizationIntegration | null { - const hecConfig = this.convertToJson(integrationResponse.configuration); - const template = this.convertToJson(configurationResponse.template); - - if (!hecConfig || !template) { - return null; - } - - const integrationConfig = new OrganizationIntegrationConfiguration( - configurationResponse.id, - integrationResponse.id, - null, - null, - "", - template, - ); - - return new OrganizationIntegration( - integrationResponse.id, - integrationResponse.type, - hecConfig.service, - hecConfig, - [integrationConfig], - ); - } - - // Could possibly be moved to a base service. All services would then assume that the - // integration configuration would always be an array and this hec specific service - // would just assume a single entry. - private setIntegrations(orgId: OrganizationId) { - const results$ = zip(this.integrationApiService.getOrganizationIntegrations(orgId)).pipe( - switchMap(([responses]) => { - const integrations: OrganizationIntegration[] = []; - const promises: Promise[] = []; - - responses.forEach((integration) => { - if (integration.type === OrganizationIntegrationType.Hec) { - const promise = this.integrationConfigurationApiService - .getOrganizationIntegrationConfigurations(orgId, integration.id) - .then((response) => { - // Hec events will only have one OrganizationIntegrationConfiguration - const config = response[0]; - - const orgIntegration = this.mapResponsesToOrganizationIntegration( - integration, - config, - ); - - if (orgIntegration !== null) { - integrations.push(orgIntegration); - } - }); - promises.push(promise); - } - }); - return Promise.all(promises).then(() => { - return integrations; - }); - }), - ); - - return results$; - } - - // TODO: Move to base service when necessary - convertToJson(jsonString?: string): T | null { - try { - return JSON.parse(jsonString || "") as T; - } catch { - return null; - } - } -} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.spec.ts index 10ea87486b4..a03b675868d 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-api.service.spec.ts @@ -4,7 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationId, OrganizationIntegrationId } from "@bitwarden/common/types/guid"; import { OrganizationIntegrationRequest } from "../models/organization-integration-request"; -import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type"; +import { OrganizationIntegrationServiceName } from "../models/organization-integration-service-type"; import { OrganizationIntegrationType } from "../models/organization-integration-type"; import { OrganizationIntegrationApiService } from "./organization-integration-api.service"; @@ -56,7 +56,7 @@ describe("OrganizationIntegrationApiService", () => { it("should call apiService.send with correct parameters for createOrganizationIntegration", async () => { const request = new OrganizationIntegrationRequest( OrganizationIntegrationType.Hec, - `{ 'uri:' 'test.com', 'scheme:' 'bearer', 'token:' '123456789', 'service:' '${OrganizationIntegrationServiceType.CrowdStrike}' }`, + `{ 'uri:' 'test.com', 'scheme:' 'bearer', 'token:' '123456789', 'service:' '${OrganizationIntegrationServiceName.CrowdStrike}' }`, ); const orgId = "org1" as OrganizationId; @@ -76,7 +76,7 @@ describe("OrganizationIntegrationApiService", () => { it("should call apiService.send with the correct parameters for updateOrganizationIntegration", async () => { const request = new OrganizationIntegrationRequest( OrganizationIntegrationType.Hec, - `{ 'uri:' 'test.com', 'scheme:' 'bearer', 'token:' '123456789', 'service:' '${OrganizationIntegrationServiceType.CrowdStrike}' }`, + `{ 'uri:' 'test.com', 'scheme:' 'bearer', 'token:' '123456789', 'service:' '${OrganizationIntegrationServiceName.CrowdStrike}' }`, ); const orgId = "org1" as OrganizationId; const integrationId = "integration1" as OrganizationIntegrationId; diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.spec.ts new file mode 100644 index 00000000000..767c22e2014 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.spec.ts @@ -0,0 +1,633 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; + +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { + OrganizationId, + OrganizationIntegrationId, + OrganizationIntegrationConfigurationId, +} from "@bitwarden/common/types/guid"; + +import { OrgIntegrationBuilder } from "../models/integration-builder"; +import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request"; +import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response"; +import { OrganizationIntegrationRequest } from "../models/organization-integration-request"; +import { OrganizationIntegrationResponse } from "../models/organization-integration-response"; +import { OrganizationIntegrationServiceName } from "../models/organization-integration-service-type"; +import { OrganizationIntegrationType } from "../models/organization-integration-type"; + +import { OrganizationIntegrationApiService } from "./organization-integration-api.service"; +import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service"; +import { OrganizationIntegrationService } from "./organization-integration-service"; + +describe("OrganizationIntegrationService", () => { + let service: OrganizationIntegrationService; + let integrationApiService: MockProxy; + let integrationConfigurationApiService: MockProxy; + + const orgId = "org-123" as OrganizationId; + const integrationId = "integration-456" as OrganizationIntegrationId; + const configurationId = "config-789" as OrganizationIntegrationConfigurationId; + + const mockIntegrationResponse = new OrganizationIntegrationResponse({ + Id: integrationId, + Type: OrganizationIntegrationType.Hec, + Configuration: JSON.stringify({ + uri: "https://test.splunk.com", + token: "test-token", + service: OrganizationIntegrationServiceName.CrowdStrike, + }), + }); + + const mockConfigurationResponse = new OrganizationIntegrationConfigurationResponse({ + Id: configurationId, + Template: JSON.stringify({ + index: "main", + service: OrganizationIntegrationServiceName.CrowdStrike, + }), + }); + + beforeEach(() => { + integrationApiService = mock(); + integrationConfigurationApiService = mock(); + + service = new OrganizationIntegrationService( + integrationApiService, + integrationConfigurationApiService, + ); + }); + + describe("initialization", () => { + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should initialize with empty integrations", async () => { + const integrations = await firstValueFrom(service.integrations$); + expect(integrations).toEqual([]); + }); + }); + + describe("setOrganizationId", () => { + it("should fetch and set integrations for the organization", async () => { + integrationApiService.getOrganizationIntegrations.mockReturnValue( + Promise.resolve([mockIntegrationResponse]), + ); + integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue( + Promise.resolve([mockConfigurationResponse]), + ); + + service.setOrganizationId(orgId).subscribe(); + + // Wait for the observable to emit + await new Promise((resolve) => setTimeout(resolve, 100)); + + const integrations = await firstValueFrom(service.integrations$); + expect(integrations).toHaveLength(1); + expect(integrations[0].id).toBe(integrationId); + expect(integrations[0].type).toBe(OrganizationIntegrationType.Hec); + expect(integrationApiService.getOrganizationIntegrations).toHaveBeenCalledWith(orgId); + expect( + integrationConfigurationApiService.getOrganizationIntegrationConfigurations, + ).toHaveBeenCalledWith(orgId, integrationId); + }); + + it("should skip fetching if organization ID is the same", async () => { + integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([])); + + service.setOrganizationId(orgId).subscribe(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + integrationApiService.getOrganizationIntegrations.mockClear(); + + // Call again with the same org ID + service.setOrganizationId(orgId).subscribe(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(integrationApiService.getOrganizationIntegrations).not.toHaveBeenCalled(); + }); + + it("should clear existing integrations when switching organizations", async () => { + const orgId2 = "org-456" as OrganizationId; + + integrationApiService.getOrganizationIntegrations.mockReturnValue( + Promise.resolve([mockIntegrationResponse]), + ); + integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue( + Promise.resolve([mockConfigurationResponse]), + ); + + service.setOrganizationId(orgId).subscribe(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + let integrations = await firstValueFrom(service.integrations$); + expect(integrations).toHaveLength(1); + + // Switch to different org + integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([])); + service.setOrganizationId(orgId2).subscribe(); + + // Should immediately clear + integrations = await firstValueFrom(service.integrations$); + expect(integrations).toEqual([]); + }); + + it("should unsubscribe from previous fetch when setting new organization", async () => { + integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([])); + + service.setOrganizationId(orgId).subscribe(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const orgId2 = "org-456" as OrganizationId; + service.setOrganizationId(orgId2).subscribe(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should call the API for both organizations (no errors about duplicate subscriptions) + // The exact call count may vary based on observable behavior + expect(integrationApiService.getOrganizationIntegrations).toHaveBeenCalled(); + }); + + it("should handle multiple integrations", async () => { + const integration2Response = new OrganizationIntegrationResponse({ + Id: "integration-2" as OrganizationIntegrationId, + Type: OrganizationIntegrationType.Datadog, + Configuration: JSON.stringify({ + uri: "https://datadog.com", + apiKey: "test-api-key", + service: OrganizationIntegrationServiceName.Datadog, + }), + }); + + const configuration2Response = new OrganizationIntegrationConfigurationResponse({ + Id: "config-2" as OrganizationIntegrationConfigurationId, + Template: JSON.stringify({ + service: OrganizationIntegrationServiceName.Datadog, + }), + }); + + integrationApiService.getOrganizationIntegrations.mockReturnValue( + Promise.resolve([mockIntegrationResponse, integration2Response]), + ); + integrationConfigurationApiService.getOrganizationIntegrationConfigurations + .mockReturnValueOnce(Promise.resolve([mockConfigurationResponse])) + .mockReturnValueOnce(Promise.resolve([configuration2Response])); + + service.setOrganizationId(orgId).subscribe(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const integrations = await firstValueFrom(service.integrations$); + expect(integrations).toHaveLength(2); + }); + }); + + describe("save", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + "https://test.splunk.com", + "test-token", + OrganizationIntegrationServiceName.CrowdStrike, + ); + const template = OrgIntegrationBuilder.buildHecTemplate( + "main", + OrganizationIntegrationServiceName.CrowdStrike, + ); + + beforeEach(() => { + // Set the organization first + integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([])); + service.setOrganizationId(orgId).subscribe(); + }); + + it("should save a new integration successfully", async () => { + integrationApiService.createOrganizationIntegration.mockResolvedValue( + mockIntegrationResponse, + ); + integrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockResolvedValue( + mockConfigurationResponse, + ); + + const result = await service.save(orgId, OrganizationIntegrationType.Hec, config, template); + + expect(result).toEqual({ mustBeOwner: false, success: true }); + expect(integrationApiService.createOrganizationIntegration).toHaveBeenCalledWith( + orgId, + expect.any(OrganizationIntegrationRequest), + ); + expect( + integrationConfigurationApiService.createOrganizationIntegrationConfiguration, + ).toHaveBeenCalledWith( + orgId, + integrationId, + expect.any(OrganizationIntegrationConfigurationRequest), + ); + + const integrations = await firstValueFrom(service.integrations$); + expect(integrations).toHaveLength(1); + expect(integrations[0].id).toBe(integrationId); + }); + + it("should throw error when organization ID mismatch", async () => { + const differentOrgId = "different-org" as OrganizationId; + + await expect( + service.save(differentOrgId, OrganizationIntegrationType.Hec, config, template), + ).rejects.toThrow("Organization ID mismatch"); + }); + + it("should return mustBeOwner true when API returns 404", async () => { + const error = new ErrorResponse({}, 404); + integrationApiService.createOrganizationIntegration.mockRejectedValue(error); + + const result = await service.save(orgId, OrganizationIntegrationType.Hec, config, template); + + expect(result).toEqual({ mustBeOwner: true, success: false }); + }); + + it("should rethrow non-404 errors", async () => { + const error = new Error("Server error"); + integrationApiService.createOrganizationIntegration.mockRejectedValue(error); + + await expect( + service.save(orgId, OrganizationIntegrationType.Hec, config, template), + ).rejects.toThrow("Server error"); + }); + + it("should handle configuration creation failure with 404", async () => { + const error = new ErrorResponse({}, 404); + integrationApiService.createOrganizationIntegration.mockResolvedValue( + mockIntegrationResponse, + ); + integrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockRejectedValue( + error, + ); + + const result = await service.save(orgId, OrganizationIntegrationType.Hec, config, template); + + expect(result).toEqual({ mustBeOwner: true, success: false }); + }); + }); + + describe("update", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + "https://updated.splunk.com", + "updated-token", + OrganizationIntegrationServiceName.CrowdStrike, + ); + const template = OrgIntegrationBuilder.buildHecTemplate( + "updated-index", + OrganizationIntegrationServiceName.CrowdStrike, + ); + + beforeEach(() => { + // Set the organization and add an existing integration + integrationApiService.getOrganizationIntegrations.mockReturnValue( + Promise.resolve([mockIntegrationResponse]), + ); + integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue( + Promise.resolve([mockConfigurationResponse]), + ); + service.setOrganizationId(orgId).subscribe(); + }); + + it("should update an integration successfully", async () => { + const updatedIntegrationResponse = new OrganizationIntegrationResponse({ + Id: integrationId, + Type: OrganizationIntegrationType.Hec, + Configuration: JSON.stringify({ + uri: "https://updated.splunk.com", + token: "updated-token", + service: OrganizationIntegrationServiceName.CrowdStrike, + }), + }); + + const updatedConfigurationResponse = new OrganizationIntegrationConfigurationResponse({ + Id: configurationId, + Template: JSON.stringify({ + index: "updated-index", + service: OrganizationIntegrationServiceName.CrowdStrike, + }), + }); + + integrationApiService.updateOrganizationIntegration.mockResolvedValue( + updatedIntegrationResponse, + ); + integrationConfigurationApiService.updateOrganizationIntegrationConfiguration.mockResolvedValue( + updatedConfigurationResponse, + ); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = await service.update( + orgId, + integrationId, + OrganizationIntegrationType.Hec, + configurationId, + config, + template, + ); + + expect(result).toEqual({ mustBeOwner: false, success: true }); + expect(integrationApiService.updateOrganizationIntegration).toHaveBeenCalledWith( + orgId, + integrationId, + expect.any(OrganizationIntegrationRequest), + ); + expect( + integrationConfigurationApiService.updateOrganizationIntegrationConfiguration, + ).toHaveBeenCalledWith( + orgId, + integrationId, + configurationId, + expect.any(OrganizationIntegrationConfigurationRequest), + ); + + const integrations = await firstValueFrom(service.integrations$); + expect(integrations).toHaveLength(1); + expect(integrations[0].id).toBe(integrationId); + }); + + it("should throw error when organization ID mismatch", async () => { + const differentOrgId = "different-org" as OrganizationId; + + await expect( + service.update( + differentOrgId, + integrationId, + OrganizationIntegrationType.Hec, + configurationId, + config, + template, + ), + ).rejects.toThrow("Organization ID mismatch"); + }); + + it("should return mustBeOwner true when API returns 404", async () => { + const error = new ErrorResponse({}, 404); + integrationApiService.updateOrganizationIntegration.mockRejectedValue(error); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = await service.update( + orgId, + integrationId, + OrganizationIntegrationType.Hec, + configurationId, + config, + template, + ); + + expect(result).toEqual({ mustBeOwner: true, success: false }); + }); + + it("should rethrow non-404 errors", async () => { + const error = new Error("Server error"); + integrationApiService.updateOrganizationIntegration.mockRejectedValue(error); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + await expect( + service.update( + orgId, + integrationId, + OrganizationIntegrationType.Hec, + configurationId, + config, + template, + ), + ).rejects.toThrow("Server error"); + }); + + it("should replace old integration with updated one in the list", async () => { + // Add multiple integrations first + const integration2Response = new OrganizationIntegrationResponse({ + Id: "integration-2" as OrganizationIntegrationId, + Type: OrganizationIntegrationType.Hec, + Configuration: mockIntegrationResponse.configuration, + }); + const configuration2Response = new OrganizationIntegrationConfigurationResponse({ + Id: "config-2" as OrganizationIntegrationConfigurationId, + Template: mockConfigurationResponse.template, + }); + + const orgId2 = "org-456" as OrganizationId; + integrationApiService.getOrganizationIntegrations.mockReturnValue( + Promise.resolve([mockIntegrationResponse, integration2Response]), + ); + integrationConfigurationApiService.getOrganizationIntegrationConfigurations + .mockReturnValue(Promise.resolve([mockConfigurationResponse])) + .mockReturnValueOnce(Promise.resolve([mockConfigurationResponse])) + .mockReturnValueOnce(Promise.resolve([configuration2Response])); + + service.setOrganizationId(orgId2).subscribe(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + let integrations = await firstValueFrom(service.integrations$); + expect(integrations).toHaveLength(2); + + // Now update the first integration + integrationApiService.updateOrganizationIntegration.mockResolvedValue( + mockIntegrationResponse, + ); + integrationConfigurationApiService.updateOrganizationIntegrationConfiguration.mockResolvedValue( + mockConfigurationResponse, + ); + + await service.update( + orgId2, + integrationId, + OrganizationIntegrationType.Hec, + configurationId, + config, + template, + ); + + integrations = await firstValueFrom(service.integrations$); + expect(integrations).toHaveLength(2); + expect(integrations.find((i) => i.id === integrationId)).toBeDefined(); + expect(integrations.find((i) => i.id === "integration-2")).toBeDefined(); + }); + }); + + describe("delete", () => { + beforeEach(() => { + // Set the organization and add an existing integration + integrationApiService.getOrganizationIntegrations.mockReturnValue( + Promise.resolve([mockIntegrationResponse]), + ); + integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue( + Promise.resolve([mockConfigurationResponse]), + ); + service.setOrganizationId(orgId).subscribe(); + }); + + it("should delete an integration successfully", async () => { + integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockResolvedValue( + undefined, + ); + integrationApiService.deleteOrganizationIntegration.mockResolvedValue(undefined); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + let integrations = await firstValueFrom(service.integrations$); + expect(integrations).toHaveLength(1); + + const result = await service.delete(orgId, integrationId, configurationId); + + expect(result).toEqual({ mustBeOwner: false, success: true }); + expect( + integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration, + ).toHaveBeenCalledWith(orgId, integrationId, configurationId); + expect(integrationApiService.deleteOrganizationIntegration).toHaveBeenCalledWith( + orgId, + integrationId, + ); + + integrations = await firstValueFrom(service.integrations$); + expect(integrations).toHaveLength(0); + }); + + it("should delete configuration before integration", async () => { + const callOrder: string[] = []; + + integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockImplementation( + async () => { + callOrder.push("configuration"); + }, + ); + integrationApiService.deleteOrganizationIntegration.mockImplementation(async () => { + callOrder.push("integration"); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + await service.delete(orgId, integrationId, configurationId); + + expect(callOrder).toEqual(["configuration", "integration"]); + }); + + it("should throw error when organization ID mismatch", async () => { + const differentOrgId = "different-org" as OrganizationId; + + await expect(service.delete(differentOrgId, integrationId, configurationId)).rejects.toThrow( + "Organization ID mismatch", + ); + }); + + it("should return mustBeOwner true when API returns 404", async () => { + const error = new ErrorResponse({}, 404); + integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockRejectedValue( + error, + ); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = await service.delete(orgId, integrationId, configurationId); + + expect(result).toEqual({ mustBeOwner: true, success: false }); + }); + + it("should rethrow non-404 errors", async () => { + const error = new Error("Server error"); + integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockRejectedValue( + error, + ); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + await expect(service.delete(orgId, integrationId, configurationId)).rejects.toThrow( + "Server error", + ); + }); + + it("should handle 404 error when deleting integration", async () => { + const error = new ErrorResponse({}, 404); + integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration.mockResolvedValue( + undefined, + ); + integrationApiService.deleteOrganizationIntegration.mockRejectedValue(error); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = await service.delete(orgId, integrationId, configurationId); + + expect(result).toEqual({ mustBeOwner: true, success: false }); + }); + }); + + describe("mapResponsesToOrganizationIntegration", () => { + it("should return null if configuration cannot be built", () => { + const invalidIntegrationResponse = new OrganizationIntegrationResponse({ + Id: integrationId, + Type: 999 as OrganizationIntegrationType, // Invalid type + Configuration: "invalid-json", + }); + + // The buildConfiguration method throws for unsupported types + // In production, this error is caught in the setIntegrations pipeline + expect(() => + service["mapResponsesToOrganizationIntegration"]( + invalidIntegrationResponse, + mockConfigurationResponse, + ), + ).toThrow("Unsupported integration type: 999"); + }); + + it("should handle template with invalid data", () => { + const invalidConfigurationResponse = new OrganizationIntegrationConfigurationResponse({ + Id: configurationId, + Template: "{}", // Empty template, will have undefined values but won't return null + }); + + const result = service["mapResponsesToOrganizationIntegration"]( + mockIntegrationResponse, + invalidConfigurationResponse, + ); + + // The result won't be null, but will have a template with undefined/default values + expect(result).not.toBeNull(); + expect(result?.integrationConfiguration[0].template).toBeDefined(); + }); + + it("should successfully map valid responses to OrganizationIntegration", () => { + const result = service["mapResponsesToOrganizationIntegration"]( + mockIntegrationResponse, + mockConfigurationResponse, + ); + + expect(result).not.toBeNull(); + expect(result?.id).toBe(integrationId); + expect(result?.type).toBe(OrganizationIntegrationType.Hec); + expect(result?.integrationConfiguration).toHaveLength(1); + expect(result?.integrationConfiguration[0].id).toBe(configurationId); + }); + }); + + describe("edge cases", () => { + it("should handle empty integration list from API", async () => { + integrationApiService.getOrganizationIntegrations.mockReturnValue(Promise.resolve([])); + + service.setOrganizationId(orgId).subscribe(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const integrations = await firstValueFrom(service.integrations$); + expect(integrations).toEqual([]); + }); + + it("should handle errors when fetching integrations", async () => { + const validIntegration = mockIntegrationResponse; + + integrationApiService.getOrganizationIntegrations.mockReturnValue( + Promise.resolve([validIntegration]), + ); + integrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReturnValue( + Promise.resolve([mockConfigurationResponse]), + ); + + service.setOrganizationId(orgId).subscribe(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const integrations = await firstValueFrom(service.integrations$); + expect(integrations).toHaveLength(1); + expect(integrations[0].id).toBe(integrationId); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.ts new file mode 100644 index 00000000000..cd153bc1133 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/organization-integration-service.ts @@ -0,0 +1,313 @@ +import { BehaviorSubject, map, Observable, of, switchMap, tap, zip } from "rxjs"; + +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { + OrganizationId, + OrganizationIntegrationId, + OrganizationIntegrationConfigurationId, +} from "@bitwarden/common/types/guid"; + +import { + OrgIntegrationBuilder, + OrgIntegrationConfiguration, + OrgIntegrationTemplate, +} from "../models/integration-builder"; +import { OrganizationIntegration } from "../models/organization-integration"; +import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration"; +import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request"; +import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response"; +import { OrganizationIntegrationRequest } from "../models/organization-integration-request"; +import { OrganizationIntegrationResponse } from "../models/organization-integration-response"; +import { OrganizationIntegrationType } from "../models/organization-integration-type"; + +import { OrganizationIntegrationApiService } from "./organization-integration-api.service"; +import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service"; +/** + * Common result type for integration modification operations (save, update, delete). + * was the server side failure due to insufficient permissions (must be owner)? + */ +export type IntegrationModificationResult = { + mustBeOwner: boolean; + success: boolean; +}; + +/** + * Provides common functionality for managing integrations with different external services. + */ +export class OrganizationIntegrationService { + private organizationId$ = new BehaviorSubject(null); + private _integrations$ = new BehaviorSubject([]); + + integrations$: Observable = this._integrations$.asObservable(); + + constructor( + protected integrationApiService: OrganizationIntegrationApiService, + protected integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService, + ) {} + + /** + * Sets the organization Id and triggers the retrieval of integrations for the given organization. + * The integrations will be available via the integrations$ observable. + * If the organization ID is the same as the current one, no action is taken. + * Use this method to kick off loading integrations for a specific organization. + * Use integrations$ to subscribe to the loaded integrations. + * + * @param orgId - The organization ID to set + * @returns Observable that completes when the operation is done. Subscribe to trigger the load. + */ + setOrganizationId(orgId: OrganizationId): Observable { + if (orgId === this.organizationId$.getValue()) { + return of(void 0); + } + this._integrations$.next([]); + this.organizationId$.next(orgId); + + // subscribe to load and set integrations + // use integrations$ to get the loaded integrations + return this.setIntegrations(orgId).pipe( + tap((integrations) => { + this._integrations$.next(integrations); + }), + map((): void => void 0), + ); + } + + /** + * Saves a new organization integration and updates the integrations$ observable. + * + * @param organizationId - ID of the organization + * @param integrationType - Type of the organization integration + * @param config - The configuration object for this integration + * @param template - The template object for this integration + * @returns Promise with the result indicating success or failure reason + */ + async save( + organizationId: OrganizationId, + integrationType: OrganizationIntegrationType, + config: OrgIntegrationConfiguration, + template: OrgIntegrationTemplate, + ): Promise { + if (organizationId !== this.organizationId$.getValue()) { + throw new Error("Organization ID mismatch"); + } + + try { + const configString = config.toString(); + const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration( + organizationId, + new OrganizationIntegrationRequest(integrationType, configString), + ); + + const templateString = template.toString(); + const newIntegrationConfigResponse = + await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration( + organizationId, + newIntegrationResponse.id, + new OrganizationIntegrationConfigurationRequest(null, null, null, templateString), + ); + + const newIntegration = this.mapResponsesToOrganizationIntegration( + newIntegrationResponse, + newIntegrationConfigResponse, + ); + if (newIntegration !== null) { + this._integrations$.next([...this._integrations$.getValue(), newIntegration]); + } + return { mustBeOwner: false, success: true }; + } catch (error) { + if (error instanceof ErrorResponse && error.statusCode === 404) { + return { mustBeOwner: true, success: false }; + } + throw error; + } + } + + /** + * Updates an existing organization integration and updates the integrations$ observable. + * + * @param organizationId - ID of the organization + * @param integrationId - ID of the organization integration + * @param integrationType - Type of the organization integration + * @param configurationId - ID of the organization integration configuration + * @param config - The updated configuration object + * @param template - The updated template object + * @returns Promise with the result indicating success or failure reason + */ + async update( + organizationId: OrganizationId, + integrationId: OrganizationIntegrationId, + integrationType: OrganizationIntegrationType, + configurationId: OrganizationIntegrationConfigurationId, + config: OrgIntegrationConfiguration, + template: OrgIntegrationTemplate, + ): Promise { + if (organizationId !== this.organizationId$.getValue()) { + throw new Error("Organization ID mismatch"); + } + + try { + const configString = config.toString(); + const updatedIntegrationResponse = + await this.integrationApiService.updateOrganizationIntegration( + organizationId, + integrationId, + new OrganizationIntegrationRequest(integrationType, configString), + ); + + const templateString = template.toString(); + const updatedIntegrationConfigResponse = + await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration( + organizationId, + integrationId, + configurationId, + new OrganizationIntegrationConfigurationRequest(null, null, null, templateString), + ); + + const updatedIntegration = this.mapResponsesToOrganizationIntegration( + updatedIntegrationResponse, + updatedIntegrationConfigResponse, + ); + + if (updatedIntegration !== null) { + const integrations = this._integrations$.getValue(); + const index = integrations.findIndex((i) => i.id === integrationId); + if (index !== -1) { + integrations[index] = updatedIntegration; + } else { + integrations.push(updatedIntegration); + } + this._integrations$.next([...integrations]); + } + return { mustBeOwner: false, success: true }; + } catch (error) { + if (error instanceof ErrorResponse && error.statusCode === 404) { + return { mustBeOwner: true, success: false }; + } + throw error; + } + } + + /** + * Deletes an organization integration and updates the integrations$ observable. + * + * @param organizationId - ID of the organization + * @param integrationId - ID of the organization integration + * @param configurationId - ID of the organization integration configuration + * @returns Promise with the result indicating success or failure reason + */ + async delete( + organizationId: OrganizationId, + integrationId: OrganizationIntegrationId, + configurationId: OrganizationIntegrationConfigurationId, + ): Promise { + if (organizationId !== this.organizationId$.getValue()) { + throw new Error("Organization ID mismatch"); + } + + try { + // delete the configuration first due to foreign key constraint + await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration( + organizationId, + integrationId, + configurationId, + ); + + // delete the integration + await this.integrationApiService.deleteOrganizationIntegration(organizationId, integrationId); + + // update the local observable + const updatedIntegrations = this._integrations$ + .getValue() + .filter((i) => i.id !== integrationId); + this._integrations$.next(updatedIntegrations); + + return { mustBeOwner: false, success: true }; + } catch (error) { + if (error instanceof ErrorResponse && error.statusCode === 404) { + return { mustBeOwner: true, success: false }; + } + throw error; + } + } + + /** + * Maps API responses to an OrganizationIntegration domain model. + * + * @param integrationResponse - The integration response from the API + * @param configurationResponse - The configuration response from the API + * @returns OrganizationIntegration or null if mapping fails + */ + private mapResponsesToOrganizationIntegration( + integrationResponse: OrganizationIntegrationResponse, + configurationResponse: OrganizationIntegrationConfigurationResponse, + ): OrganizationIntegration | null { + const integrationType = integrationResponse.type; + const config = OrgIntegrationBuilder.buildConfiguration( + integrationType, + integrationResponse.configuration, + ); + const template = OrgIntegrationBuilder.buildTemplate( + integrationType, + configurationResponse.template ?? "{}", + ); + + if (!config || !template) { + return null; + } + + const integrationConfig = new OrganizationIntegrationConfiguration( + configurationResponse.id, + integrationResponse.id, + null, + "", + template, + ); + + return new OrganizationIntegration( + integrationResponse.id, + integrationResponse.type, + config.service, + config, + [integrationConfig], + ); + } + + /** + * Fetches integrations for the given organization from the API. + * + * @param orgId - Organization ID to fetch integrations for + * @returns Observable of OrganizationIntegration array + */ + private setIntegrations(orgId: OrganizationId): Observable { + const results$ = zip(this.integrationApiService.getOrganizationIntegrations(orgId)).pipe( + switchMap(([responses]) => { + const integrations: OrganizationIntegration[] = []; + const promises: Promise[] = []; + + responses.forEach((integration) => { + const promise = this.integrationConfigurationApiService + .getOrganizationIntegrationConfigurations(orgId, integration.id) + .then((response) => { + // Integration will only have one OrganizationIntegrationConfiguration + const config = response[0]; + + const orgIntegration = this.mapResponsesToOrganizationIntegration( + integration, + config, + ); + + if (orgIntegration !== null) { + integrations.push(orgIntegration); + } + }); + promises.push(promise); + }); + return Promise.all(promises).then(() => { + return integrations; + }); + }), + ); + + return results$; + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts index 8beaae7f10a..37bd504643c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts @@ -4,9 +4,10 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; -import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; -import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service"; -import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; +import { OrgIntegrationBuilder } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder"; +import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; +import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type"; +import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; @@ -29,8 +30,7 @@ describe("IntegrationCardComponent", () => { let fixture: ComponentFixture; const mockI18nService = mock(); const activatedRoute = mock(); - const mockIntegrationService = mock(); - const mockDatadogIntegrationService = mock(); + const mockIntegrationService = mock(); const dialogService = mock(); const toastService = mock(); @@ -54,8 +54,7 @@ describe("IntegrationCardComponent", () => { { provide: I18nPipe, useValue: mock() }, { provide: I18nService, useValue: mockI18nService }, { provide: ActivatedRoute, useValue: activatedRoute }, - { provide: HecOrganizationIntegrationService, useValue: mockIntegrationService }, - { provide: DatadogOrganizationIntegrationService, useValue: mockDatadogIntegrationService }, + { provide: OrganizationIntegrationService, useValue: mockIntegrationService }, { provide: ToastService, useValue: toastService }, { provide: DialogService, useValue: dialogService }, ], @@ -259,7 +258,7 @@ describe("IntegrationCardComponent", () => { configuration: {}, integrationConfiguration: [{ id: "config-id" }], }, - name: OrganizationIntegrationServiceType.CrowdStrike, + name: OrganizationIntegrationServiceName.CrowdStrike, } as any; component.organizationId = "org-id" as any; jest.resetAllMocks(); @@ -270,8 +269,8 @@ describe("IntegrationCardComponent", () => { closed: of({ success: false }), }); await component.setupConnection(); - expect(mockIntegrationService.updateHec).not.toHaveBeenCalled(); - expect(mockIntegrationService.saveHec).not.toHaveBeenCalled(); + expect(mockIntegrationService.update).not.toHaveBeenCalled(); + expect(mockIntegrationService.save).not.toHaveBeenCalled(); }); it("should call updateHec if isUpdateAvailable is true", async () => { @@ -284,26 +283,35 @@ describe("IntegrationCardComponent", () => { }), }); + const config = OrgIntegrationBuilder.buildHecConfiguration( + "test-url", + "token", + OrganizationIntegrationServiceName.CrowdStrike, + ); + const template = OrgIntegrationBuilder.buildHecTemplate( + "index", + OrganizationIntegrationServiceName.CrowdStrike, + ); + jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); await component.setupConnection(); - expect(mockIntegrationService.updateHec).toHaveBeenCalledWith( + expect(mockIntegrationService.update).toHaveBeenCalledWith( "org-id", "integration-id", + OrganizationIntegrationType.Hec, "config-id", - OrganizationIntegrationServiceType.CrowdStrike, - "test-url", - "token", - "index", + config, + template, ); - expect(mockIntegrationService.saveHec).not.toHaveBeenCalled(); + expect(mockIntegrationService.save).not.toHaveBeenCalled(); }); it("should call saveHec if isUpdateAvailable is false", async () => { component.integrationSettings = { organizationIntegration: null, - name: OrganizationIntegrationServiceType.CrowdStrike, + name: OrganizationIntegrationServiceName.CrowdStrike, } as any; component.organizationId = "org-id" as any; @@ -316,23 +324,32 @@ describe("IntegrationCardComponent", () => { }), }); + const config = OrgIntegrationBuilder.buildHecConfiguration( + "test-url", + "token", + OrganizationIntegrationServiceName.CrowdStrike, + ); + const template = OrgIntegrationBuilder.buildHecTemplate( + "index", + OrganizationIntegrationServiceName.CrowdStrike, + ); + jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(false); - mockIntegrationService.saveHec.mockResolvedValue({ mustBeOwner: false, success: true }); + mockIntegrationService.save.mockResolvedValue({ mustBeOwner: false, success: true }); await component.setupConnection(); - expect(mockIntegrationService.saveHec).toHaveBeenCalledWith( + expect(mockIntegrationService.save).toHaveBeenCalledWith( "org-id", - OrganizationIntegrationServiceType.CrowdStrike, - "test-url", - "token", - "index", + OrganizationIntegrationType.Hec, + config, + template, ); - expect(mockIntegrationService.updateHec).not.toHaveBeenCalled(); + expect(mockIntegrationService.update).not.toHaveBeenCalled(); }); - it("should call deleteHec when a delete is requested", async () => { + it("should call delete with Hec type when a delete is requested", async () => { component.organizationId = "org-id" as any; (openHecConnectDialog as jest.Mock).mockReturnValue({ @@ -344,22 +361,22 @@ describe("IntegrationCardComponent", () => { }), }); - mockIntegrationService.deleteHec.mockResolvedValue({ mustBeOwner: false, success: true }); + mockIntegrationService.delete.mockResolvedValue({ mustBeOwner: false, success: true }); await component.setupConnection(); - expect(mockIntegrationService.deleteHec).toHaveBeenCalledWith( + expect(mockIntegrationService.delete).toHaveBeenCalledWith( "org-id", "integration-id", "config-id", ); - expect(mockIntegrationService.saveHec).not.toHaveBeenCalled(); + expect(mockIntegrationService.save).not.toHaveBeenCalled(); }); - it("should not call deleteHec if no existing configuration", async () => { + it("should not call delete if no existing configuration", async () => { component.integrationSettings = { organizationIntegration: null, - name: OrganizationIntegrationServiceType.CrowdStrike, + name: OrganizationIntegrationServiceName.CrowdStrike, } as any; component.organizationId = "org-id" as any; @@ -372,20 +389,16 @@ describe("IntegrationCardComponent", () => { }), }); - mockIntegrationService.deleteHec.mockResolvedValue({ mustBeOwner: false, success: true }); + mockIntegrationService.delete.mockResolvedValue({ mustBeOwner: false, success: true }); await component.setupConnection(); - expect(mockIntegrationService.deleteHec).not.toHaveBeenCalledWith( + expect(mockIntegrationService.delete).not.toHaveBeenCalledWith( "org-id", "integration-id", "config-id", - OrganizationIntegrationServiceType.CrowdStrike, - "test-url", - "token", - "index", ); - expect(mockIntegrationService.updateHec).not.toHaveBeenCalled(); + expect(mockIntegrationService.update).not.toHaveBeenCalled(); }); it("should show toast on error while saving", async () => { @@ -399,11 +412,11 @@ describe("IntegrationCardComponent", () => { }); jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); - mockIntegrationService.updateHec.mockRejectedValue(new Error("fail")); + mockIntegrationService.update.mockRejectedValue(new Error("fail")); await component.setupConnection(); - expect(mockIntegrationService.updateHec).toHaveBeenCalled(); + expect(mockIntegrationService.update).toHaveBeenCalled(); expect(toastService.showToast).toHaveBeenCalledWith({ variant: "error", title: "", @@ -422,11 +435,11 @@ describe("IntegrationCardComponent", () => { }); jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); - mockIntegrationService.updateHec.mockRejectedValue(new ErrorResponse("Not Found", 404)); + mockIntegrationService.update.mockRejectedValue(new ErrorResponse("Not Found", 404)); await component.setupConnection(); - expect(mockIntegrationService.updateHec).toHaveBeenCalled(); + expect(mockIntegrationService.update).toHaveBeenCalled(); expect(toastService.showToast).toHaveBeenCalledWith({ variant: "error", title: "", @@ -445,11 +458,10 @@ describe("IntegrationCardComponent", () => { }); jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); - mockIntegrationService.updateHec.mockRejectedValue(new ErrorResponse("Not Found", 404)); - + mockIntegrationService.update.mockRejectedValue(new ErrorResponse("Not Found", 404)); await component.setupConnection(); - expect(mockIntegrationService.updateHec).toHaveBeenCalled(); + expect(mockIntegrationService.update).toHaveBeenCalled(); expect(toastService.showToast).toHaveBeenCalledWith({ variant: "error", title: "", @@ -468,11 +480,11 @@ describe("IntegrationCardComponent", () => { }); jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); - mockIntegrationService.deleteHec.mockRejectedValue(new Error("fail")); + mockIntegrationService.delete.mockRejectedValue(new Error("fail")); await component.setupConnection(); - expect(mockIntegrationService.deleteHec).toHaveBeenCalled(); + expect(mockIntegrationService.delete).toHaveBeenCalled(); expect(toastService.showToast).toHaveBeenCalledWith({ variant: "error", title: "", @@ -491,11 +503,10 @@ describe("IntegrationCardComponent", () => { }); jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); - mockIntegrationService.deleteHec.mockRejectedValue(new ErrorResponse("Not Found", 404)); - + mockIntegrationService.delete.mockRejectedValue(new ErrorResponse("Not Found", 404)); await component.setupConnection(); - expect(mockIntegrationService.deleteHec).toHaveBeenCalled(); + expect(mockIntegrationService.delete).toHaveBeenCalled(); expect(toastService.showToast).toHaveBeenCalledWith({ variant: "error", title: "", diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts index e6d4aff05fb..8026e14c2fc 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts @@ -12,10 +12,10 @@ import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rx import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; -import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; +import { OrgIntegrationBuilder } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder"; +import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type"; -import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service"; -import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; +import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; @@ -96,8 +96,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { private systemTheme$: Observable, private dialogService: DialogService, private activatedRoute: ActivatedRoute, - private hecOrganizationIntegrationService: HecOrganizationIntegrationService, - private datadogOrganizationIntegrationService: DatadogOrganizationIntegrationService, + private organizationIntegrationService: OrganizationIntegrationService, private toastService: ToastService, private i18nService: I18nService, ) { @@ -250,7 +249,18 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } async saveHec(result: HecConnectDialogResult) { - let saveResponse = { mustBeOwner: false, success: false }; + let response = { mustBeOwner: false, success: false }; + + const config = OrgIntegrationBuilder.buildHecConfiguration( + result.url, + result.bearerToken, + this.integrationSettings.name as OrganizationIntegrationServiceName, + ); + const template = OrgIntegrationBuilder.buildHecTemplate( + result.index, + this.integrationSettings.name as OrganizationIntegrationServiceName, + ); + if (this.isUpdateAvailable) { // retrieve org integration and configuration ids const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; @@ -262,27 +272,25 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } // update existing integration and configuration - saveResponse = await this.hecOrganizationIntegrationService.updateHec( + response = await this.organizationIntegrationService.update( this.organizationId, orgIntegrationId, + OrganizationIntegrationType.Hec, orgIntegrationConfigurationId, - this.integrationSettings.name as OrganizationIntegrationServiceType, - result.url, - result.bearerToken, - result.index, + config, + template, ); } else { // create new integration and configuration - saveResponse = await this.hecOrganizationIntegrationService.saveHec( + response = await this.organizationIntegrationService.save( this.organizationId, - this.integrationSettings.name as OrganizationIntegrationServiceType, - result.url, - result.bearerToken, - result.index, + OrganizationIntegrationType.Hec, + config, + template, ); } - if (saveResponse.mustBeOwner) { + if (response.mustBeOwner) { this.showMustBeOwnerToast(); return; } @@ -303,7 +311,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { throw Error("Organization Integration ID or Configuration ID is missing"); } - const response = await this.hecOrganizationIntegrationService.deleteHec( + const response = await this.organizationIntegrationService.delete( this.organizationId, orgIntegrationId, orgIntegrationConfigurationId, @@ -322,6 +330,13 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } async saveDatadog(result: DatadogConnectDialogResult) { + let response = { mustBeOwner: false, success: false }; + + const config = OrgIntegrationBuilder.buildDataDogConfiguration(result.url, result.apiKey); + const template = OrgIntegrationBuilder.buildDataDogTemplate( + this.integrationSettings.name as OrganizationIntegrationServiceName, + ); + if (this.isUpdateAvailable) { // retrieve org integration and configuration ids const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; @@ -333,23 +348,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } // update existing integration and configuration - await this.datadogOrganizationIntegrationService.updateDatadog( + response = await this.organizationIntegrationService.update( this.organizationId, orgIntegrationId, + OrganizationIntegrationType.Datadog, orgIntegrationConfigurationId, - this.integrationSettings.name as OrganizationIntegrationServiceType, - result.url, - result.apiKey, + config, + template, ); } else { // create new integration and configuration - await this.datadogOrganizationIntegrationService.saveDatadog( + response = await this.organizationIntegrationService.save( this.organizationId, - this.integrationSettings.name as OrganizationIntegrationServiceType, - result.url, - result.apiKey, + OrganizationIntegrationType.Datadog, + config, + template, ); } + + if (response.mustBeOwner) { + this.showMustBeOwnerToast(); + return; + } + this.toastService.showToast({ variant: "success", title: "", @@ -366,7 +387,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { throw Error("Organization Integration ID or Configuration ID is missing"); } - const response = await this.datadogOrganizationIntegrationService.deleteDatadog( + const response = await this.organizationIntegrationService.delete( this.organizationId, orgIntegrationId, orgIntegrationConfigurationId, diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.spec.ts index 2908fe0c089..3560a32fb40 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.spec.ts @@ -6,8 +6,7 @@ import { of } from "rxjs"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; -import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service"; -import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; +import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; import { IntegrationType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeTypes } from "@bitwarden/common/platform/enums"; @@ -24,8 +23,7 @@ describe("IntegrationGridComponent", () => { let component: IntegrationGridComponent; let fixture: ComponentFixture; const mockActivatedRoute = mock(); - const mockIntegrationService = mock(); - const mockDatadogIntegrationService = mock(); + const mockIntegrationService = mock(); const integrations: Integration[] = [ { name: "Integration 1", @@ -71,8 +69,7 @@ describe("IntegrationGridComponent", () => { provide: ActivatedRoute, useValue: mockActivatedRoute, }, - { provide: HecOrganizationIntegrationService, useValue: mockIntegrationService }, - { provide: DatadogOrganizationIntegrationService, useValue: mockDatadogIntegrationService }, + { provide: OrganizationIntegrationService, useValue: mockIntegrationService }, { provide: ToastService, useValue: mock(), diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html index 58c52e4f40a..a35df3677bb 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html @@ -1,69 +1,78 @@ - - -
-

{{ "singleSignOn" | i18n }}

-

- {{ "ssoDescStart" | i18n }} - {{ "singleSignOn" | i18n }} - {{ "ssoDescEnd" | i18n }} -

- -
-
+@let organization = organization$ | async; - -
-

- {{ "scimIntegration" | i18n }} -

-

- {{ "scimIntegrationDescStart" | i18n }} - {{ "scimIntegration" | i18n }} - {{ "scimIntegrationDescEnd" | i18n }} -

- -
-
-

- {{ "bwdc" | i18n }} -

-

{{ "bwdcDesc" | i18n }}

- -
-
+@if (organization) { + + @if (organization?.useSso) { + +
+

{{ "singleSignOn" | i18n }}

+

+ {{ "ssoDescStart" | i18n }} + {{ + "singleSignOn" | i18n + }} + {{ "ssoDescEnd" | i18n }} +

+ +
+
+ } - -
-

- {{ "eventManagement" | i18n }} -

-

{{ "eventManagementDesc" | i18n }}

- -
-
+ @if (organization?.useScim || organization?.useDirectory) { + +
+

+ {{ "scimIntegration" | i18n }} +

+

+ {{ "scimIntegrationDescStart" | i18n }} + {{ "scimIntegration" | i18n }} + {{ "scimIntegrationDescEnd" | i18n }} +

+ +
+
+

+ {{ "bwdc" | i18n }} +

+

{{ "bwdcDesc" | i18n }}

+ +
+
+ } - -
-

- {{ "deviceManagement" | i18n }} -

-

{{ "deviceManagementDesc" | i18n }}

- -
-
-
+ @if (organization?.useEvents) { + +
+

+ {{ "eventManagement" | i18n }} +

+

{{ "eventManagementDesc" | i18n }}

+ +
+
+ } + + +
+

+ {{ "deviceManagement" | i18n }} +

+

{{ "deviceManagementDesc" | i18n }}

+ +
+
+
+} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts index 894a8e9a25c..6517182b21e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts @@ -3,10 +3,9 @@ import { ActivatedRoute } from "@angular/router"; import { firstValueFrom, Observable, Subject, switchMap, takeUntil, takeWhile } from "rxjs"; import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; -import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; +import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type"; -import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service"; -import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; +import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -21,6 +20,7 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { IntegrationGridComponent } from "./integration-grid/integration-grid.component"; import { FilterIntegrationsPipe } from "./integrations.pipe"; +// attempted, but because bit-tab-group is not OnPush, caused more issues than it solved // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -236,10 +236,12 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { ); // Sets the organization ID which also loads the integrations$ - this.organization$.pipe(takeUntil(this.destroy$)).subscribe((org) => { - this.hecOrganizationIntegrationService.setOrganizationIntegrations(org.id); - this.datadogOrganizationIntegrationService.setOrganizationIntegrations(org.id); - }); + this.organization$ + .pipe( + switchMap((org) => this.organizationIntegrationService.setOrganizationId(org.id)), + takeUntil(this.destroy$), + ) + .subscribe(); } constructor( @@ -247,8 +249,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { private organizationService: OrganizationService, private accountService: AccountService, private configService: ConfigService, - private hecOrganizationIntegrationService: HecOrganizationIntegrationService, - private datadogOrganizationIntegrationService: DatadogOrganizationIntegrationService, + private organizationIntegrationService: OrganizationIntegrationService, ) { this.configService .getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike) @@ -260,7 +261,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { // Add the new event based items to the list if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) { const crowdstrikeIntegration: Integration = { - name: OrganizationIntegrationServiceType.CrowdStrike, + name: OrganizationIntegrationServiceName.CrowdStrike, linkURL: "https://bitwarden.com/help/crowdstrike-siem/", image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg", type: IntegrationType.EVENT, @@ -272,7 +273,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.integrationsList.push(crowdstrikeIntegration); const datadogIntegration: Integration = { - name: OrganizationIntegrationServiceType.Datadog, + name: OrganizationIntegrationServiceName.Datadog, linkURL: "https://bitwarden.com/help/datadog-siem/", image: "../../../../../../../images/integrations/logo-datadog-color.svg", type: IntegrationType.EVENT, @@ -286,42 +287,23 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { // For all existing event based configurations loop through and assign the // organizationIntegration for the correct services. - this.hecOrganizationIntegrationService.integrations$ + this.organizationIntegrationService.integrations$ .pipe(takeUntil(this.destroy$)) .subscribe((integrations) => { - // reset all integrations to null first - in case one was deleted + // reset all event based integrations to null first - in case one was deleted this.integrationsList.forEach((i) => { - if (i.integrationType === OrganizationIntegrationType.Hec) { - i.organizationIntegration = null; - } + i.organizationIntegration = null; }); - integrations.map((integration) => { - const item = this.integrationsList.find((i) => i.name === integration.serviceType); - if (item) { - item.organizationIntegration = integration; - } - }); - }); - - this.datadogOrganizationIntegrationService.integrations$ - .pipe(takeUntil(this.destroy$)) - .subscribe((integrations) => { - // reset all integrations to null first - in case one was deleted - this.integrationsList.forEach((i) => { - if (i.integrationType === OrganizationIntegrationType.Datadog) { - i.organizationIntegration = null; - } - }); - - integrations.map((integration) => { - const item = this.integrationsList.find((i) => i.name === integration.serviceType); + integrations.forEach((integration) => { + const item = this.integrationsList.find((i) => i.name === integration.serviceName); if (item) { item.organizationIntegration = integration; } }); }); } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts index e3c37b4a42b..789ae548521 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts @@ -1,9 +1,8 @@ import { NgModule } from "@angular/core"; -import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service"; -import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-api.service"; import { OrganizationIntegrationConfigurationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-configuration-api.service"; +import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { safeProvider } from "@bitwarden/ui-common"; @@ -14,13 +13,8 @@ import { OrganizationIntegrationsRoutingModule } from "./organization-integratio imports: [AdminConsoleIntegrationsComponent, OrganizationIntegrationsRoutingModule], providers: [ safeProvider({ - provide: DatadogOrganizationIntegrationService, - useClass: DatadogOrganizationIntegrationService, - deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService], - }), - safeProvider({ - provide: HecOrganizationIntegrationService, - useClass: HecOrganizationIntegrationService, + provide: OrganizationIntegrationService, + useClass: OrganizationIntegrationService, deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService], }), safeProvider({ diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts index 0e8c46c8864..7a02e3fb04e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts @@ -9,8 +9,7 @@ import {} from "@bitwarden/web-vault/app/shared"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; -import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service"; -import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; +import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; @@ -41,8 +40,7 @@ class MockNewMenuComponent {} describe("IntegrationsComponent", () => { let fixture: ComponentFixture; - const hecOrgIntegrationSvc = mock(); - const datadogOrgIntegrationSvc = mock(); + const orgIntegrationSvc = mock(); const activatedRouteMock = { snapshot: { paramMap: { get: jest.fn() } }, @@ -60,8 +58,7 @@ describe("IntegrationsComponent", () => { { provide: ActivatedRoute, useValue: activatedRouteMock }, { provide: I18nPipe, useValue: mock() }, { provide: I18nService, useValue: mockI18nService }, - { provide: HecOrganizationIntegrationService, useValue: hecOrgIntegrationSvc }, - { provide: DatadogOrganizationIntegrationService, useValue: datadogOrgIntegrationSvc }, + { provide: OrganizationIntegrationService, useValue: orgIntegrationSvc }, ], }).compileComponents(); fixture = TestBed.createComponent(IntegrationsComponent); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.module.ts index 04240da3176..bcfbb9b3f2c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.module.ts @@ -1,9 +1,8 @@ import { NgModule } from "@angular/core"; -import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service"; -import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-api.service"; import { OrganizationIntegrationConfigurationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-configuration-api.service"; +import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { safeProvider } from "@bitwarden/ui-common"; @@ -23,13 +22,8 @@ import { IntegrationsComponent } from "./integrations.component"; ], providers: [ safeProvider({ - provide: DatadogOrganizationIntegrationService, - useClass: DatadogOrganizationIntegrationService, - deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService], - }), - safeProvider({ - provide: HecOrganizationIntegrationService, - useClass: HecOrganizationIntegrationService, + provide: OrganizationIntegrationService, + useClass: OrganizationIntegrationService, deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService], }), safeProvider({