diff --git a/apps/web/src/app/admin-console/organizations/integrations/integrations.component.ts b/apps/web/src/app/admin-console/organizations/integrations/integrations.component.ts index 3ddf9c0a720..91e219da6f3 100644 --- a/apps/web/src/app/admin-console/organizations/integrations/integrations.component.ts +++ b/apps/web/src/app/admin-console/organizations/integrations/integrations.component.ts @@ -2,10 +2,10 @@ // @ts-strict-ignore import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { Observable, Subject, switchMap, takeUntil, scheduled, asyncScheduler } from "rxjs"; +import { Observable, Subject, switchMap, takeUntil, combineLatest } from "rxjs"; // eslint-disable-next-line no-restricted-imports -import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations"; +import { HecConfiguration } from "@bitwarden/bit-common/dirt/integrations"; import { getOrganizationById, OrganizationService, @@ -22,6 +22,7 @@ import { SharedOrganizationModule } from "../shared"; import { IntegrationGridComponent } from "../shared/components/integrations/integration-grid/integration-grid.component"; import { FilterIntegrationsPipe } from "../shared/components/integrations/integrations.pipe"; import { Integration } from "../shared/components/integrations/models"; +import { OrganizationIntegrationService } from "../shared/components/integrations/services/organization-integration.service"; @Component({ selector: "ac-integrations", @@ -218,9 +219,11 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { }, ]; - ngOnInit(): void { + async ngOnInit(): Promise { const orgId = this.route.snapshot.params.organizationId; + await this.organizationIntegrationService.getIntegrationsAndConfigurations(orgId); + this.organization$ = this.route.params.pipe( switchMap((params) => this.accountService.activeAccount$.pipe( @@ -233,23 +236,41 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { ), ); - scheduled(this.orgIntegrationApiService.getOrganizationIntegrations(orgId), asyncScheduler) + combineLatest([ + this.organizationIntegrationService.integrations$, + this.organizationIntegrationService.integrationConfigurations$, + ]) .pipe(takeUntil(this.destroy$)) - .subscribe((integrations) => { + .subscribe(([integrations, integrationConfigurations]) => { + const existingIntegrations = [...this.integrationsList]; + // Update the integrations list with the fetched integrations if (integrations && integrations.length > 0) { integrations.forEach((integration) => { - const configJson = JSON.parse(integration.configuration || "{}"); - const serviceName = configJson.service ?? ""; - const existingIntegration = this.integrationsList.find((i) => i.name === serviceName); + const configJson = this.organizationIntegrationService.convertToJson( + integration.configuration, + ); + const serviceName = configJson?.service ?? ""; + const existingIntegration = existingIntegrations.find((i) => i.name === serviceName); if (existingIntegration) { - // if a configuration exists, then it is connected + // update integrations existingIntegration.isConnected = !!integration.configuration; existingIntegration.configuration = integration.configuration || ""; + + const template = this.organizationIntegrationService.getIntegrationConfiguration( + integration.id, + serviceName, + integrationConfigurations, + ); + + existingIntegration.template = JSON.stringify(template || {}); } }); } + + // update the integrations list + this.integrationsList = existingIntegrations; }); } @@ -258,7 +279,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { private organizationService: OrganizationService, private accountService: AccountService, private configService: ConfigService, - private orgIntegrationApiService: OrganizationIntegrationApiService, + private organizationIntegrationService: OrganizationIntegrationService, ) { this.configService .getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations) @@ -284,6 +305,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.destroy$.complete(); } + // use in the view get IntegrationType(): typeof IntegrationType { return IntegrationType; } diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/index.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/index.ts index c8fe9d32652..c4537de01b2 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/index.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/index.ts @@ -2,3 +2,4 @@ export * from "./integrations.pipe"; export * from "./integration-card/integration-card.component"; export * from "./integration-grid/integration-grid.component"; export * from "./models"; +export * from "./services/organization-integration.service"; diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.spec.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.spec.ts index 382d245b235..9c1c31a796f 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.spec.ts @@ -4,8 +4,6 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; -// eslint-disable-next-line no-restricted-imports -import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations/services"; 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"; @@ -14,6 +12,8 @@ import { ToastService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/components/src/shared"; import { I18nPipe } from "@bitwarden/ui-common"; +import { OrganizationIntegrationService } from "../services/organization-integration.service"; + import { IntegrationCardComponent } from "./integration-card.component"; describe("IntegrationCardComponent", () => { @@ -21,7 +21,7 @@ describe("IntegrationCardComponent", () => { let fixture: ComponentFixture; const mockI18nService = mock(); const activatedRoute = mock(); - const mockOrgIntegrationApiService = mock(); + const mockIntegrationService = mock(); const systemTheme$ = new BehaviorSubject(ThemeType.Light); const usersPreferenceTheme$ = new BehaviorSubject(ThemeType.Light); @@ -43,7 +43,7 @@ describe("IntegrationCardComponent", () => { { provide: I18nPipe, useValue: mock() }, { provide: I18nService, useValue: mockI18nService }, { provide: ActivatedRoute, useValue: activatedRoute }, - { provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService }, + { provide: OrganizationIntegrationService, useValue: mockIntegrationService }, { provide: ToastService, useValue: mock() }, ], }).compileComponents(); diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts index 829e65af17e..ec411b03cb3 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts @@ -15,11 +15,9 @@ import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rx import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; // eslint-disable-next-line no-restricted-imports import { - OrganizationIntegrationType, - OrganizationIntegrationRequest, - OrganizationIntegrationResponse, - OrganizationIntegrationApiService, -} from "@bitwarden/bit-common/dirt/integrations/index"; + HecConfiguration, + HecConfigurationTemplate, +} from "@bitwarden/bit-common/dirt/integrations"; 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"; @@ -29,6 +27,7 @@ import { DialogService, ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../../../../shared/shared.module"; import { openHecConnectDialog } from "../integration-dialog/index"; import { Integration } from "../models"; +import { OrganizationIntegrationService } from "../services/organization-integration.service"; @Component({ selector: "app-integration-card", @@ -59,18 +58,24 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { @Input() isConnected?: boolean; @Input() canSetupConnection?: boolean; + organizationId: OrganizationId; + constructor( private themeStateService: ThemeStateService, @Inject(SYSTEM_THEME_OBSERVABLE) private systemTheme$: Observable, private dialogService: DialogService, private activatedRoute: ActivatedRoute, - private apiService: OrganizationIntegrationApiService, + private organizationIntegrationService: OrganizationIntegrationService, private toastService: ToastService, private i18nService: I18nService, ) {} ngAfterViewInit() { + this.organizationId = this.activatedRoute.snapshot.paramMap.get( + "organizationId", + ) as OrganizationId; + combineLatest([this.themeStateService.selectedTheme$, this.systemTheme$]) .pipe(takeUntil(this.destroyed$)) .subscribe(([theme, systemTheme]) => { @@ -125,6 +130,12 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { const dialog = openHecConnectDialog(this.dialogService, { data: { settings: this.integrationSettings, + configuration: this.organizationIntegrationService.convertToJson( + this.integrationSettings.configuration, + ), + template: this.organizationIntegrationService.convertToJson( + this.integrationSettings.template, + ), }, }); @@ -135,11 +146,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { return; } + // create integration and configuration objects + const integration = new HecConfiguration(result.url, result.bearerToken, result.service); + const configurationTemplate = new HecConfigurationTemplate(result.index, result.service); + // save the integration try { - const dbResponse = await this.saveHecIntegration(result.configuration); - this.isConnected = !!dbResponse.id; - } catch { + const dbResponse = await this.organizationIntegrationService.saveHec( + this.organizationId, + this.integrationSettings.name, + integration, + configurationTemplate, + ); + + if (!!dbResponse.integration && !!dbResponse.configuration) { + this.isConnected = true; + this.integrationSettings.configuration = dbResponse.integration.configuration; + this.integrationSettings.template = dbResponse.configuration.template; + } + } catch (err) { + this.isConnected = false; + // eslint-disable-next-line no-console + console.error("Failed to save integration", err); + this.toastService.showToast({ variant: "error", title: null, @@ -147,34 +176,5 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { }); return; } - - // update the configuration - this.integrationSettings.configuration = result.configuration; - } - - async saveHecIntegration(configuration: string): Promise { - const organizationId = this.activatedRoute.snapshot.paramMap.get( - "organizationId", - ) as OrganizationId; - - const request = new OrganizationIntegrationRequest( - OrganizationIntegrationType.Hec, - configuration, - ); - - const integrations = await this.apiService.getOrganizationIntegrations(organizationId); - const existingIntegration = integrations.find( - (i) => i.type === OrganizationIntegrationType.Hec, - ); - - if (existingIntegration) { - return await this.apiService.updateOrganizationIntegration( - organizationId, - existingIntegration.id, - request, - ); - } else { - return await this.apiService.createOrganizationIntegration(organizationId, request); - } } } diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts index 9be854545aa..0f44582fe3b 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts @@ -150,12 +150,10 @@ describe("ConnectDialogHecComponent", () => { expect(dialogRefMock.close).toHaveBeenCalledWith({ integrationSettings: integrationMock, - configuration: JSON.stringify({ - url: "https://test.com", - bearerToken: "token", - index: "1", - service: "Test Service", - }), + url: "https://test.com", + bearerToken: "token", + index: "1", + service: "Test Service", success: true, error: null, }); diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts index c0af17db8d7..eb80841b82c 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts @@ -1,6 +1,11 @@ import { Component, Inject, OnInit } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; +// eslint-disable-next-line no-restricted-imports +import { + HecConfiguration, + HecConfigurationTemplate, +} from "@bitwarden/bit-common/dirt/integrations"; import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; @@ -8,11 +13,16 @@ import { Integration } from "../../models"; export type HecConnectDialogParams = { settings: Integration; + configuration: HecConfiguration | null; + template: HecConfigurationTemplate | null; }; export interface HecConnectDialogResult { integrationSettings: Integration; - configuration: string; + url: string; + bearerToken: string; + index: string; + service: string; success: boolean; error: string | null; } @@ -37,16 +47,12 @@ export class ConnectHecDialogComponent implements OnInit { ) {} ngOnInit(): void { - const settings = this.getSettingsAsJson(this.connectInfo.settings.configuration ?? ""); - - if (settings) { - this.formGroup.patchValue({ - url: settings?.url || "", - bearerToken: settings?.bearerToken || "", - index: settings?.index || "", - service: this.connectInfo.settings.name, - }); - } + this.formGroup.patchValue({ + url: this.connectInfo.configuration?.uri || "", + bearerToken: this.connectInfo.configuration?.token || "", + index: this.connectInfo.template?.index || "", + service: this.connectInfo.settings.name, + }); } getSettingsAsJson(configuration: string) { @@ -62,7 +68,10 @@ export class ConnectHecDialogComponent implements OnInit { const result: HecConnectDialogResult = { integrationSettings: this.connectInfo.settings, - configuration: JSON.stringify(formJson), + url: formJson.url, + bearerToken: formJson.bearerToken, + index: formJson.index, + service: formJson.service, success: true, error: null, }; diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component.spec.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component.spec.ts index 01a512ac38c..7924b51ca73 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component.spec.ts @@ -5,8 +5,6 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; -// eslint-disable-next-line no-restricted-imports -import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations/services"; import { IntegrationType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeTypes } from "@bitwarden/common/platform/enums"; @@ -21,6 +19,7 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { IntegrationCardComponent } from "../integration-card/integration-card.component"; import { Integration } from "../models"; +import { OrganizationIntegrationService } from "../services/organization-integration.service"; import { IntegrationGridComponent } from "./integration-grid.component"; @@ -28,7 +27,7 @@ describe("IntegrationGridComponent", () => { let component: IntegrationGridComponent; let fixture: ComponentFixture; const mockActivatedRoute = mock(); - const mockOrgIntegrationApiService = mock(); + const mockIntegrationService = mock(); const integrations: Integration[] = [ { name: "Integration 1", @@ -74,10 +73,7 @@ describe("IntegrationGridComponent", () => { provide: ActivatedRoute, useValue: mockActivatedRoute, }, - { - provide: OrganizationIntegrationApiService, - useValue: mockOrgIntegrationApiService, - }, + { provide: OrganizationIntegrationService, useValue: mockIntegrationService }, { provide: ToastService, useValue: mock(), diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/models.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/models.ts index b3d24ffb3b0..b781beb8466 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/models.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/models.ts @@ -21,4 +21,5 @@ export type Integration = { isConnected?: boolean; canSetupConnection?: boolean; configuration?: string; + template?: string; }; diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.spec.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.spec.ts new file mode 100644 index 00000000000..d1b866d043f --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.spec.ts @@ -0,0 +1,347 @@ +import { TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; + +// eslint-disable-next-line no-restricted-imports +import { + OrganizationIntegrationApiService, + OrganizationIntegrationConfigurationApiService, +} from "@bitwarden/bit-common/dirt/integrations"; + +import { OrganizationIntegrationService } from "./organization-integration.service"; + +describe("OrganizationIntegrationService", () => { + let service: OrganizationIntegrationService; + const mockOrgIntegrationApiService = mock(); + const mockOrgIntegrationConfigurationApiService = + mock(); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService }, + { + provide: OrganizationIntegrationConfigurationApiService, + useValue: mockOrgIntegrationConfigurationApiService, + }, + ], + }); + service = TestBed.inject(OrganizationIntegrationService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + describe("getIntegrationsAndConfigurations", () => { + const orgId = "org-123" as any; + + beforeEach(() => { + mockOrgIntegrationApiService.getOrganizationIntegrations.mockReset(); + mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReset(); + service["integrations"].next([]); + service["integrationConfigurations"].next([]); + }); + + it("should fetch integrations and their configurations and update observables", async () => { + const integration1 = { id: "int-1", type: "type1" } as any; + const integration2 = { id: "int-2", type: "type2" } as any; + const config1 = [{ id: "conf-1", template: "{}" }] as any; + const config2 = [{ id: "conf-2", template: "{}" }] as any; + + mockOrgIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([ + integration1, + integration2, + ]); + mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockImplementation( + (_, integrationId) => { + if (integrationId === "int-1") { + return Promise.resolve(config1); + } + if (integrationId === "int-2") { + return Promise.resolve(config2); + } + return Promise.resolve([]); + }, + ); + + const result = await service.getIntegrationsAndConfigurations(orgId); + + expect(mockOrgIntegrationApiService.getOrganizationIntegrations).toHaveBeenCalledWith(orgId); + expect( + mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations, + ).toHaveBeenCalledWith(orgId, "int-1"); + expect( + mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations, + ).toHaveBeenCalledWith(orgId, "int-2"); + + expect(result.integrations).toEqual([integration1, integration2]); + expect(result.configurations.length).toBe(2); + expect(result.configurations[0].integrationId).toBe("int-1"); + expect(result.configurations[1].integrationId).toBe("int-2"); + + // Check observables updated + service.integrations$.subscribe((integrations) => { + expect(integrations).toEqual([integration1, integration2]); + }); + service.integrationConfigurations$.subscribe((configs) => { + expect(configs.length).toBe(2); + }); + }); + + it("should handle no integrations", async () => { + mockOrgIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([]); + const result = await service.getIntegrationsAndConfigurations(orgId); + + expect(result.integrations).toEqual([]); + expect(result.configurations).toEqual([]); + service.integrations$.subscribe((integrations) => { + expect(integrations).toEqual([]); + }); + service.integrationConfigurations$.subscribe((configs) => { + expect(configs).toEqual([]); + }); + }); + + it("should handle integrations with no configurations", async () => { + const integration = { id: "int-1", type: "type1" } as any; + mockOrgIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([integration]); + mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockResolvedValue( + [], + ); + + const result = await service.getIntegrationsAndConfigurations(orgId); + + expect(result.integrations).toEqual([integration]); + expect(result.configurations.length).toBe(1); + expect(result.configurations[0].integrationId).toBe("int-1"); + expect(result.configurations[0].configurationResponses).toEqual([]); + }); + }); + + describe("saveHec", () => { + const organizationId = "org-123" as any; + const serviceName = "splunk"; + const hecConfiguration = { toString: () => '{"token":"abc"}' } as any; + const hecConfigurationTemplate = { toString: () => '{"service":"splunk"}' } as any; + + beforeEach(() => { + mockOrgIntegrationApiService.getOrganizationIntegrations.mockReset(); + mockOrgIntegrationConfigurationApiService.getOrganizationIntegrationConfigurations.mockReset(); + service["integrations"].next([]); + service["integrationConfigurations"].next([]); + jest.clearAllMocks(); + }); + + it("should save HEC integration and configuration successfully", async () => { + const integrationResponse = { id: "int-1", type: "Hec" } as any; + const configurationResponse = { id: "conf-1", template: '{"service":"splunk"}' } as any; + + jest.spyOn(service, "saveHecIntegration").mockResolvedValue(integrationResponse); + jest + .spyOn(service, "saveHecIntegrationConfiguration") + .mockResolvedValue(configurationResponse); + + const result = await service.saveHec( + organizationId, + serviceName, + hecConfiguration, + hecConfigurationTemplate, + ); + + expect(service.saveHecIntegration).toHaveBeenCalledWith(organizationId, hecConfiguration); + expect(service.saveHecIntegrationConfiguration).toHaveBeenCalledWith( + organizationId, + integrationResponse.id, + serviceName, + hecConfigurationTemplate, + ); + expect(result).toEqual({ + integration: integrationResponse, + configuration: configurationResponse, + }); + }); + + it("should throw error if integrationResponse.id is missing", async () => { + const integrationResponse = { type: "Hec" } as any; + jest.spyOn(service, "saveHecIntegration").mockResolvedValue(integrationResponse); + + await expect( + service.saveHec(organizationId, serviceName, hecConfiguration, hecConfigurationTemplate), + ).rejects.toThrow("Failed to save HEC integration"); + }); + + it("should throw error if configurationResponse.id is missing", async () => { + const integrationResponse = { id: "int-1", type: "Hec" } as any; + const configurationResponse = { template: '{"service":"splunk"}' } as any; + + jest.spyOn(service, "saveHecIntegration").mockResolvedValue(integrationResponse); + jest + .spyOn(service, "saveHecIntegrationConfiguration") + .mockResolvedValue(configurationResponse); + + await expect( + service.saveHec(organizationId, serviceName, hecConfiguration, hecConfigurationTemplate), + ).rejects.toThrow("Failed to save HEC integration configuration"); + }); + }); + + describe("getIntegrationConfiguration", () => { + const integrationId = "int-1" as any; + const serviceName = "splunk"; + const otherServiceName = "datadog"; + + it("should return null if integrationConfigurations is empty", () => { + const result = service.getIntegrationConfiguration(integrationId, serviceName, []); + expect(result).toBeNull(); + }); + + it("should return null if no integrationConfigs found for integrationId", () => { + const integrationConfigurations = [ + { + integrationId: "int-2", + configurationResponses: [{ id: "conf-1", template: '{"service":"splunk"}' }], + }, + ] as any; + const result = service.getIntegrationConfiguration( + integrationId, + serviceName, + integrationConfigurations, + ); + expect(result).toBeNull(); + }); + + it("should return null if no configurationResponses match the service", () => { + const sampleIntegrationConfigurations = [ + { + integrationId, + configurationResponses: [{ id: "conf-1", template: '{"service":"splunk"}' }], + }, + ] as any; + const result = service.getIntegrationConfiguration( + integrationId, + otherServiceName, + sampleIntegrationConfigurations, + ); + expect(result).toBeNull(); + }); + + it("should return the configuration template if service matches", () => { + const template = { service: "splunk", token: "abc" }; + const integrationConfigurations = [ + { + integrationId, + configurationResponses: [ + { id: "conf-1", template: JSON.stringify(template) }, + { id: "conf-2", template: '{"service":"datadog"}' }, + ], + }, + ] as any; + const result = service.getIntegrationConfiguration( + integrationId, + serviceName, + integrationConfigurations, + ); + expect(result).toEqual(template); + }); + + it("should return the first matching configuration if multiple match", () => { + const template1 = { service: "splunk", token: "abc" }; + const template2 = { service: "splunk", token: "def" }; + const integrationConfigurations = [ + { + integrationId, + configurationResponses: [ + { id: "conf-1", template: JSON.stringify(template1) }, + { id: "conf-2", template: JSON.stringify(template2) }, + ], + }, + ] as any; + const result = service.getIntegrationConfiguration( + integrationId, + serviceName, + integrationConfigurations, + ); + expect(result).toEqual(template1); + }); + + it("should skip invalid JSON templates", () => { + const template = { service: "splunk", token: "abc" }; + const integrationConfigurations = [ + { + integrationId, + configurationResponses: [ + { id: "conf-1", template: "invalid-json" }, + { id: "conf-2", template: JSON.stringify(template) }, + ], + }, + ] as any; + const result = service.getIntegrationConfiguration( + integrationId, + serviceName, + integrationConfigurations, + ); + expect(result).toEqual(template); + }); + + it("should return null if all templates are invalid JSON", () => { + const integrationConfigurations = [ + { + integrationId, + configurationResponses: [ + { id: "conf-1", template: "invalid-json" }, + { id: "conf-2", template: "" }, + ], + }, + ] as any; + const result = service.getIntegrationConfiguration( + integrationId, + serviceName, + integrationConfigurations, + ); + expect(result).toBeNull(); + }); + }); + + describe("convertToJson", () => { + it("should parse valid JSON string and return object", () => { + const jsonString = '{"foo":"bar","num":42}'; + const result = service.convertToJson<{ foo: string; num: number }>(jsonString); + expect(result).toEqual({ foo: "bar", num: 42 }); + }); + + it("should return null for invalid JSON string", () => { + const invalidJson = '{"foo":bar}'; + const result = service.convertToJson(invalidJson); + expect(result).toBeNull(); + }); + + it("should return null for empty string", () => { + const result = service.convertToJson(""); + expect(result).toBeNull(); + }); + + it("should parse JSON arrays", () => { + const jsonString = "[1,2,3]"; + const result = service.convertToJson(jsonString); + expect(result).toEqual([1, 2, 3]); + }); + + it("should parse JSON boolean", () => { + const jsonString = "true"; + const result = service.convertToJson(jsonString); + expect(result).toBe(true); + }); + + it("should parse JSON number", () => { + const jsonString = "123"; + const result = service.convertToJson(jsonString); + expect(result).toBe(123); + }); + + it("should parse JSON null", () => { + const jsonString = "null"; + const result = service.convertToJson(jsonString); + expect(result).toBeNull(); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.ts new file mode 100644 index 00000000000..94b6bcdda1a --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/services/organization-integration.service.ts @@ -0,0 +1,281 @@ +import { Injectable } from "@angular/core"; +import { BehaviorSubject } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports +import { + OrganizationIntegrationApiService, + OrganizationIntegrationConfigurationApiService, + OrganizationIntegrationConfigurationRequest, + OrganizationIntegrationConfigurationResponse, + HecConfiguration, + OrganizationIntegrationRequest, + OrganizationIntegrationResponse, + OrganizationIntegrationType, + HecConfigurationTemplate, + OrganizationIntegrationConfigurationResponseWithIntegrationId, +} from "@bitwarden/bit-common/dirt/integrations"; +import { EventType } from "@bitwarden/common/enums"; +import { OrganizationId, OrganizationIntegrationId } from "@bitwarden/common/types/guid"; + +@Injectable({ + providedIn: "root", +}) +export class OrganizationIntegrationService { + private integrations = new BehaviorSubject([]); + integrations$ = this.integrations.asObservable(); + + private integrationConfigurations = new BehaviorSubject< + OrganizationIntegrationConfigurationResponseWithIntegrationId[] + >([]); + integrationConfigurations$ = this.integrationConfigurations.asObservable(); + + constructor( + private integrationApiService: OrganizationIntegrationApiService, + private integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService, + ) {} + + /* + * Fetches the integrations and their configurations for a specific organization. + * @param orgId The ID of the organization. + * Invoke this method to retrieve the integrations into observable. + */ + async getIntegrationsAndConfigurations(orgId: OrganizationId) { + const promises: Promise[] = []; + + const integrations = await this.integrationApiService.getOrganizationIntegrations(orgId); + const integrationConfigurations: OrganizationIntegrationConfigurationResponseWithIntegrationId[] = + []; + + integrations.forEach((integration) => { + const promise = this.integrationConfigurationApiService + .getOrganizationIntegrationConfigurations(orgId, integration.id) + .then((configs) => { + const mappedConfigurations = + new OrganizationIntegrationConfigurationResponseWithIntegrationId( + integration.id, + configs, + ); + integrationConfigurations.push(mappedConfigurations); + }); + promises.push(promise); + }); + + await Promise.all(promises); + + this.integrations.next(integrations); + this.integrationConfigurations.next(integrationConfigurations); + + return { + integrations: integrations, + configurations: integrationConfigurations, + }; + } + + async saveHec( + organizationId: OrganizationId, + service: string, + hecConfiguration: HecConfiguration, + hecConfigurationTemplate: HecConfigurationTemplate, + ) { + const integrationResponse = await this.saveHecIntegration(organizationId, hecConfiguration); + + if (!integrationResponse.id) { + throw new Error("Failed to save HEC integration"); + } + + // Save the configuration for the HEC integration + const configurationResponse = await this.saveHecIntegrationConfiguration( + organizationId, + integrationResponse.id, + service, + hecConfigurationTemplate, + ); + + if (!configurationResponse.id) { + throw new Error("Failed to save HEC integration configuration"); + } + + return { + integration: integrationResponse, + configuration: configurationResponse, + }; + } + + /** + * Saves the HEC integration configuration for a specific organization. + * @param organizationId The ID of the organization. + * @param configuration The HEC integration configuration. + * @param index The index of the integration configuration to update, if it exists. + * @returns The saved or updated integration response. + * + * This method checks if an existing HEC integration exists for the organization. + * If it does, it updates the existing integration; otherwise, it creates a new one. + * The method returns the saved or updated integration response. + */ + async saveHecIntegration( + organizationId: OrganizationId, + hecConfiguration: HecConfiguration, + ): Promise { + const request = new OrganizationIntegrationRequest( + OrganizationIntegrationType.Hec, + hecConfiguration.toString(), + ); + + // find the existing integration + const existingIntegration = this.integrations.value.find( + (i) => i.type === OrganizationIntegrationType.Hec, + ); + + if (existingIntegration) { + // existing integration record found, invoke update API endpoint + const updatedIntegration = await this.integrationApiService.updateOrganizationIntegration( + organizationId, + existingIntegration.id, + request, + ); + + // update our observable with the updated integration + const updatedIntegrations = this.integrations.value.map((integration) => { + if (integration.id === existingIntegration.id) { + return updatedIntegration; + } + return integration; + }); + + this.integrations.next(updatedIntegrations); + + return updatedIntegration; + } else { + // no existing integration found, invoke create API endpoint + const newIntegration = await this.integrationApiService.createOrganizationIntegration( + organizationId, + request, + ); + + // add this to our integrations observable + this.integrations.next([...this.integrations.value, newIntegration]); + return newIntegration; + } + } + + /** * Saves the HEC integration configuration for a specific organization and integration. + * @param organizationId The ID of the organization. + * @param integrationId The ID of the integration. + * @param configurationTemplate The HEC integration configuration. + * @returns The saved or updated integration configuration response. + * + * This method checks if an existing configuration exists for the given integration. + * If it does, it updates the existing configuration; otherwise, it creates a new one. + * The method returns the saved or updated configuration response. + */ + async saveHecIntegrationConfiguration( + organizationId: OrganizationId, + integrationId: OrganizationIntegrationId, + service: string, + configurationTemplate: HecConfigurationTemplate, + ): Promise { + const request = new OrganizationIntegrationConfigurationRequest( + EventType.Organization_Updated, + null, + null, + configurationTemplate.toString(), + ); + + // check if we have an existing configuration for this integration - in case of new records + const integrationConfigurations = this.integrationConfigurations.value; + + // find the existing configuration + const existingConfigurations = integrationConfigurations + .filter((config) => config.integrationId === integrationId) + .flatMap((config) => config.configurationResponses || []); + + const existingConfiguration = + existingConfigurations.length > 0 + ? existingConfigurations.find( + (config) => + config.template && + this.convertToJson(config.template)?.service === service, + ) + : null; + + if (existingConfiguration) { + // existing configuration found, invoke update API endpoint + const updatedConfiguration = + await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration( + organizationId, + integrationId, + existingConfiguration.id, + request, + ); + + // update our configurations for the integration + integrationConfigurations.forEach((integrationConfig) => { + if (integrationConfig.integrationId === integrationId) { + integrationConfig.configurationResponses = integrationConfig.configurationResponses.map( + (config) => { + return config.id === existingConfiguration.id ? updatedConfiguration : config; + }, + ); + } + }); + + this.integrationConfigurations.next(integrationConfigurations); + + return updatedConfiguration; + } else { + // no existing configuration found, invoke create API endpoint + const newConfiguration = + await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration( + organizationId, + integrationId, + request, + ); + + // add the new configuration to the integration configurations + const integrationConfig = integrationConfigurations.find( + (config) => config.integrationId === integrationId, + ); + if (integrationConfig) { + integrationConfig.configurationResponses.push(newConfiguration); + } + + this.integrationConfigurations.next(integrationConfigurations); + return newConfiguration; + } + } + + getIntegrationConfiguration( + integrationId: OrganizationIntegrationId, + service: string, + integrationConfigurations: OrganizationIntegrationConfigurationResponseWithIntegrationId[], + ): HecConfigurationTemplate | null { + if (integrationConfigurations.length === 0) { + return null; + } + + const integrationConfigs = integrationConfigurations.find( + (config) => config.integrationId === integrationId, + ); + + if (!integrationConfigs) { + return null; + } + + for (const config of integrationConfigs.configurationResponses) { + const template = this.convertToJson(config.template || ""); + if (template && template.service === service) { + return template; + } + } + + return null; + } + + convertToJson(jsonString: string): T | null { + try { + return JSON.parse(jsonString) as T; + } catch { + return null; + } + } +} diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 1c38f23c1b8..9465376df55 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -42,7 +42,10 @@ import { LoginEmailService, } from "@bitwarden/auth/common"; // eslint-disable-next-line no-restricted-imports -import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations"; +import { + OrganizationIntegrationApiService, + OrganizationIntegrationConfigurationApiService, +} from "@bitwarden/bit-common/dirt/integrations"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; @@ -402,6 +405,11 @@ const safeProviders: SafeProvider[] = [ useClass: OrganizationIntegrationApiService, deps: [ApiService], }), + safeProvider({ + provide: OrganizationIntegrationConfigurationApiService, + useClass: OrganizationIntegrationConfigurationApiService, + deps: [ApiService], + }), ]; @NgModule({ diff --git a/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-configuration-response.ts b/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-configuration-response.ts index 47baf3276ad..600edc8c7c9 100644 --- a/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-configuration-response.ts +++ b/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-configuration-response.ts @@ -1,6 +1,9 @@ import { EventType } from "@bitwarden/common/enums"; import { BaseResponse } from "@bitwarden/common/models/response/base.response"; -import { OrganizationIntegrationConfigurationId } from "@bitwarden/common/types/guid"; +import { + OrganizationIntegrationConfigurationId, + OrganizationIntegrationId, +} from "@bitwarden/common/types/guid"; export class OrganizationIntegrationConfigurationResponse extends BaseResponse { id: OrganizationIntegrationConfigurationId; @@ -18,3 +21,16 @@ export class OrganizationIntegrationConfigurationResponse extends BaseResponse { this.template = this.getResponseProperty("Template"); } } + +export class OrganizationIntegrationConfigurationResponseWithIntegrationId { + integrationId: OrganizationIntegrationId; + configurationResponses: OrganizationIntegrationConfigurationResponse[]; + + constructor( + integrationId: OrganizationIntegrationId, + configurationResponses: OrganizationIntegrationConfigurationResponse[], + ) { + this.integrationId = integrationId; + this.configurationResponses = configurationResponses; + } +} diff --git a/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-request.ts b/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-request.ts index 95f7d180dae..ac11b205352 100644 --- a/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-request.ts +++ b/bitwarden_license/bit-common/src/dirt/integrations/models/organization-integration-request.ts @@ -9,3 +9,40 @@ export class OrganizationIntegrationRequest { this.configuration = configuration; } } + +/* + * Represents the configuration for a HEC (HTTP Event Collector) integration. + * Configuration model that is required by OrganizationIntegration. + */ +export class HecConfiguration { + uri: string; + scheme = "bearer"; + token: string; + service: string; + + constructor(uri: string, token: string, service: string) { + this.uri = uri; + this.token = token; + this.service = service; + } + + toString(): string { + return JSON.stringify(this); + } +} + +export class HecConfigurationTemplate { + event = "#EventMessage"; + source = "Bitwarden"; + index: string; + service: string; + + constructor(index: string, service: string) { + this.index = index; + this.service = service; + } + + toString(): string { + return JSON.stringify(this); + } +}