diff --git a/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts b/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts index b835c711853..7dcd8a12392 100644 --- a/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts +++ b/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts @@ -70,8 +70,8 @@ export class BrowserSystemNotificationService implements SystemNotificationsServ } async clear(clearInfo: SystemNotificationClearInfo): Promise { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - chrome.notifications.clear(clearInfo.id); + await chrome.notifications.clear(clearInfo.id); + return undefined; } isSupported(): boolean { diff --git a/apps/web/src/images/integrations/logo-datadog-color.svg b/apps/web/src/images/integrations/logo-datadog-color.svg new file mode 100644 index 00000000000..62e608cd544 --- /dev/null +++ b/apps/web/src/images/integrations/logo-datadog-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index cda4e70f915..c104e9776c1 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -9994,6 +9994,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, 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 new file mode 100644 index 00000000000..e788ebba7f2 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/datadog-configuration.ts @@ -0,0 +1,17 @@ +import { OrganizationIntegrationServiceType } from "../organization-integration-service-type"; + +export class DatadogConfiguration { + uri: string; + apiKey: string; + service: OrganizationIntegrationServiceType; + + constructor(uri: string, apiKey: string, service: string) { + this.uri = uri; + this.apiKey = apiKey; + this.service = service as OrganizationIntegrationServiceType; + } + + toString(): string { + return JSON.stringify(this); + } +} 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 new file mode 100644 index 00000000000..9aa6e34f478 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/datadog-template.ts @@ -0,0 +1,17 @@ +import { OrganizationIntegrationServiceType } from "../../organization-integration-service-type"; + +export class DatadogTemplate { + 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; + + constructor(service: string) { + this.service = service as OrganizationIntegrationServiceType; + } + + toString(): string { + return JSON.stringify(this); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration.ts index abd1861caa9..2167694b720 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration.ts @@ -1,6 +1,7 @@ import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; import { OrganizationIntegration } from "./organization-integration"; +import { OrganizationIntegrationType } from "./organization-integration-type"; /** Integration or SDK */ export type Integration = { @@ -23,6 +24,7 @@ export type Integration = { canSetupConnection?: boolean; configuration?: string; template?: string; + integrationType?: OrganizationIntegrationType | null; // OrganizationIntegration organizationIntegration?: OrganizationIntegration | null; 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 d4bbd30055f..0209460b630 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,6 +4,7 @@ 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"; @@ -14,7 +15,7 @@ export class OrganizationIntegrationConfiguration { eventType?: EventType | null; configuration?: WebhookIntegrationConfigurationConfig | null; filters?: string; - template?: HecTemplate | WebhookTemplate | null; + template?: HecTemplate | WebhookTemplate | DatadogTemplate | null; constructor( id: OrganizationIntegrationConfigurationId, @@ -22,7 +23,7 @@ export class OrganizationIntegrationConfiguration { eventType?: EventType | null, configuration?: WebhookIntegrationConfigurationConfig | null, filters?: string, - template?: HecTemplate | WebhookTemplate | null, + template?: HecTemplate | WebhookTemplate | DatadogTemplate | null, ) { this.id = id; this.integrationId = integrationId; 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 dd1b4fb3f6c..e9e93adc0ff 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,5 +1,6 @@ export const OrganizationIntegrationServiceType = Object.freeze({ CrowdStrike: "CrowdStrike", + Datadog: "Datadog", } as const); export type OrganizationIntegrationServiceType = diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-type.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-type.ts index 1c98e174836..3cf68ee9b1d 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-type.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-type.ts @@ -4,6 +4,7 @@ export const OrganizationIntegrationType = Object.freeze({ Slack: 3, Webhook: 4, Hec: 5, + Datadog: 6, } as const); export type OrganizationIntegrationType = 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 abbe2271b30..d32c92a460a 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,5 +1,6 @@ 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 { OrganizationIntegrationConfiguration } from "./organization-integration-configuration"; @@ -10,14 +11,14 @@ export class OrganizationIntegration { id: OrganizationIntegrationId; type: OrganizationIntegrationType; serviceType: OrganizationIntegrationServiceType; - configuration: HecConfiguration | WebhookConfiguration | null; + configuration: HecConfiguration | WebhookConfiguration | DatadogConfiguration | null; integrationConfiguration: OrganizationIntegrationConfiguration[] = []; constructor( id: OrganizationIntegrationId, type: OrganizationIntegrationType, serviceType: OrganizationIntegrationServiceType, - configuration: HecConfiguration | WebhookConfiguration | null, + configuration: HecConfiguration | WebhookConfiguration | DatadogConfiguration | null, integrationConfiguration: OrganizationIntegrationConfiguration[] = [], ) { this.id = id; 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 new file mode 100644 index 00000000000..0545f95cb83 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.spec.ts @@ -0,0 +1,184 @@ +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 new file mode 100644 index 00000000000..1fd5e9f8c06 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.ts @@ -0,0 +1,350 @@ +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.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts index 6c6a086e0f5..ad9854c4b25 100644 --- 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 @@ -311,22 +311,24 @@ export class HecOrganizationIntegrationService { const promises: Promise[] = []; responses.forEach((integration) => { - const promise = this.integrationConfigurationApiService - .getOrganizationIntegrationConfigurations(orgId, integration.id) - .then((response) => { - // Hec events will only have one OrganizationIntegrationConfiguration - const config = response[0]; + 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, - ); + const orgIntegration = this.mapResponsesToOrganizationIntegration( + integration, + config, + ); - if (orgIntegration !== null) { - integrations.push(orgIntegration); - } - }); - promises.push(promise); + if (orgIntegration !== null) { + integrations.push(orgIntegration); + } + }); + promises.push(promise); + } }); return Promise.all(promises).then(() => { return integrations; 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 74c39613502..8beaae7f10a 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 @@ -5,6 +5,7 @@ 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 { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -29,6 +30,7 @@ describe("IntegrationCardComponent", () => { const mockI18nService = mock(); const activatedRoute = mock(); const mockIntegrationService = mock(); + const mockDatadogIntegrationService = mock(); const dialogService = mock(); const toastService = mock(); @@ -53,6 +55,7 @@ describe("IntegrationCardComponent", () => { { provide: I18nService, useValue: mockI18nService }, { provide: ActivatedRoute, useValue: activatedRoute }, { provide: HecOrganizationIntegrationService, useValue: mockIntegrationService }, + { provide: DatadogOrganizationIntegrationService, useValue: mockDatadogIntegrationService }, { provide: ToastService, useValue: toastService }, { provide: DialogService, useValue: dialogService }, ], 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 091de63d7a1..3a243f8eb91 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 @@ -13,17 +13,22 @@ 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 { 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 { 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"; import { OrganizationId } from "@bitwarden/common/types/guid"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { HecConnectDialogResult, + DatadogConnectDialogResult, HecConnectDialogResultStatus, + DatadogConnectDialogResultStatus, + openDatadogConnectDialog, openHecConnectDialog, } from "../integration-dialog/index"; @@ -64,6 +69,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { private dialogService: DialogService, private activatedRoute: ActivatedRoute, private hecOrganizationIntegrationService: HecOrganizationIntegrationService, + private datadogOrganizationIntegrationService: DatadogOrganizationIntegrationService, private toastService: ToastService, private i18nService: I18nService, ) { @@ -131,42 +137,87 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } async setupConnection() { - // invoke the dialog to connect the integration - const dialog = openHecConnectDialog(this.dialogService, { - data: { - settings: this.integrationSettings, - }, - }); + let dialog: DialogRef; - const result = await lastValueFrom(dialog.closed); - - // the dialog was cancelled - if (!result || !result.success) { + if (this.integrationSettings?.integrationType === null) { return; } - try { - if (result.success === HecConnectDialogResultStatus.Delete) { - await this.deleteHec(); - } - } catch { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("failedToDeleteIntegration"), + if (this.integrationSettings?.integrationType === OrganizationIntegrationType.Datadog) { + dialog = openDatadogConnectDialog(this.dialogService, { + data: { + settings: this.integrationSettings, + }, }); - } - try { - if (result.success === HecConnectDialogResultStatus.Edited) { - await this.saveHec(result); + const result = await lastValueFrom(dialog.closed); + + // the dialog was cancelled + if (!result || !result.success) { + return; } - } catch { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("failedToSaveIntegration"), + + try { + if (result.success === HecConnectDialogResultStatus.Delete) { + await this.deleteDatadog(); + } + } catch { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("failedToDeleteIntegration"), + }); + } + + try { + if (result.success === DatadogConnectDialogResultStatus.Edited) { + await this.saveDatadog(result as DatadogConnectDialogResult); + } + } catch { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("failedToSaveIntegration"), + }); + } + } else { + // invoke the dialog to connect the integration + dialog = openHecConnectDialog(this.dialogService, { + data: { + settings: this.integrationSettings, + }, }); + + const result = await lastValueFrom(dialog.closed); + + // the dialog was cancelled + if (!result || !result.success) { + return; + } + + try { + if (result.success === HecConnectDialogResultStatus.Delete) { + await this.deleteHec(); + } + } catch { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("failedToDeleteIntegration"), + }); + } + + try { + if (result.success === HecConnectDialogResultStatus.Edited) { + await this.saveHec(result as HecConnectDialogResult); + } + } catch { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("failedToSaveIntegration"), + }); + } } } @@ -242,6 +293,69 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { }); } + async saveDatadog(result: DatadogConnectDialogResult) { + if (this.isUpdateAvailable) { + // retrieve org integration and configuration ids + const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; + const orgIntegrationConfigurationId = + this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + + if (!orgIntegrationId || !orgIntegrationConfigurationId) { + throw Error("Organization Integration ID or Configuration ID is missing"); + } + + // update existing integration and configuration + await this.datadogOrganizationIntegrationService.updateDatadog( + this.organizationId, + orgIntegrationId, + orgIntegrationConfigurationId, + this.integrationSettings.name as OrganizationIntegrationServiceType, + result.url, + result.apiKey, + ); + } else { + // create new integration and configuration + await this.datadogOrganizationIntegrationService.saveDatadog( + this.organizationId, + this.integrationSettings.name as OrganizationIntegrationServiceType, + result.url, + result.apiKey, + ); + } + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("success"), + }); + } + + async deleteDatadog() { + const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; + const orgIntegrationConfigurationId = + this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + + if (!orgIntegrationId || !orgIntegrationConfigurationId) { + throw Error("Organization Integration ID or Configuration ID is missing"); + } + + const response = await this.datadogOrganizationIntegrationService.deleteDatadog( + this.organizationId, + orgIntegrationId, + orgIntegrationConfigurationId, + ); + + if (response.mustBeOwner) { + this.showMustBeOwnerToast(); + return; + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("success"), + }); + } + private showMustBeOwnerToast() { this.toastService.showToast({ variant: "error", diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html new file mode 100644 index 00000000000..c129216b694 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html @@ -0,0 +1,58 @@ +
+ + + {{ "connectIntegrationButtonDesc" | i18n: connectInfo.settings.name }} + +
+ @if (loading) { + + + + } + @if (!loading) { + + + {{ "url" | i18n }} + + + + + {{ "apiKey" | i18n }} + + {{ "apiKey" | i18n }} + + + } +
+ + + + + @if (canDelete) { +
+ +
+ } +
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts new file mode 100644 index 00000000000..7298087e7e4 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts @@ -0,0 +1,171 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; + +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { IntegrationType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { + ConnectDatadogDialogComponent, + DatadogConnectDialogParams, + DatadogConnectDialogResult, + DatadogConnectDialogResultStatus, + openDatadogConnectDialog, +} from "./connect-dialog-datadog.component"; + +beforeAll(() => { + // Mock element.animate for jsdom + // the animate function is not available in jsdom, so we provide a mock implementation + // This is necessary for tests that rely on animations + // This mock does not perform any actual animations, it just provides a structure that allows tests + // to run without throwing errors related to missing animate function + if (!HTMLElement.prototype.animate) { + HTMLElement.prototype.animate = function () { + return { + play: () => {}, + pause: () => {}, + finish: () => {}, + cancel: () => {}, + reverse: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + onfinish: null, + oncancel: null, + startTime: 0, + currentTime: 0, + playbackRate: 1, + playState: "idle", + replaceState: "active", + effect: null, + finished: Promise.resolve(), + id: "", + remove: () => {}, + timeline: null, + ready: Promise.resolve(), + } as unknown as Animation; + }; + } +}); + +describe("ConnectDialogDatadogComponent", () => { + let component: ConnectDatadogDialogComponent; + let fixture: ComponentFixture; + let dialogRefMock = mock>(); + const mockI18nService = mock(); + + const integrationMock: Integration = { + name: "Test Integration", + image: "test-image.png", + linkURL: "https://example.com", + imageDarkMode: "test-image-dark.png", + newBadgeExpiration: "2024-12-31", + description: "Test Description", + canSetupConnection: true, + type: IntegrationType.EVENT, + } as Integration; + const connectInfo: DatadogConnectDialogParams = { + settings: integrationMock, // Provide appropriate mock template if needed + }; + + beforeEach(async () => { + dialogRefMock = mock>(); + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, SharedModule, BrowserAnimationsModule], + providers: [ + FormBuilder, + { provide: DIALOG_DATA, useValue: connectInfo }, + { provide: DialogRef, useValue: dialogRefMock }, + { provide: I18nPipe, useValue: mock() }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConnectDatadogDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + mockI18nService.t.mockImplementation((key) => key); + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize form with empty values", () => { + expect(component.formGroup.value).toEqual({ + url: "", + apiKey: "", + service: "Test Integration", + }); + }); + + it("should have required validators for all fields", () => { + component.formGroup.setValue({ url: "", apiKey: "", service: "" }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://test.com", + apiKey: "token", + service: "Test Service", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should test url is at least 7 characters long", () => { + component.formGroup.setValue({ + url: "test", + apiKey: "token", + service: "Test Service", + }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://test.com", + apiKey: "token", + service: "Test Service", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should call dialogRef.close with correct result on submit", async () => { + component.formGroup.setValue({ + url: "https://test.com", + apiKey: "token", + service: "Test Service", + }); + + await component.submit(); + + expect(dialogRefMock.close).toHaveBeenCalledWith({ + integrationSettings: integrationMock, + url: "https://test.com", + apiKey: "token", + service: "Test Service", + success: DatadogConnectDialogResultStatus.Edited, + }); + }); +}); + +describe("openDatadogConnectDialog", () => { + it("should call dialogService.open with correct params", () => { + const dialogServiceMock = mock(); + const config: DialogConfig< + DatadogConnectDialogParams, + DialogRef + > = { + data: { settings: { name: "Test" } as Integration }, + } as any; + + openDatadogConnectDialog(dialogServiceMock, config); + + expect(dialogServiceMock.open).toHaveBeenCalledWith(ConnectDatadogDialogComponent, config); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts new file mode 100644 index 00000000000..d186910d2f7 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts @@ -0,0 +1,121 @@ +import { Component, Inject, OnInit } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; + +import { DatadogConfiguration } from "@bitwarden/bit-common/dirt/organization-integrations/models/configuration/datadog-configuration"; +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template"; +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +export type DatadogConnectDialogParams = { + settings: Integration; +}; + +export interface DatadogConnectDialogResult { + integrationSettings: Integration; + url: string; + apiKey: string; + service: string; + success: DatadogConnectDialogResultStatusType | null; +} + +export const DatadogConnectDialogResultStatus = { + Edited: "edit", + Delete: "delete", +} as const; + +export type DatadogConnectDialogResultStatusType = + (typeof DatadogConnectDialogResultStatus)[keyof typeof DatadogConnectDialogResultStatus]; + +@Component({ + templateUrl: "./connect-dialog-datadog.component.html", + imports: [SharedModule], +}) +export class ConnectDatadogDialogComponent implements OnInit { + loading = false; + datadogConfig: DatadogConfiguration | null = null; + hecTemplate: HecTemplate | null = null; + formGroup = this.formBuilder.group({ + url: ["", [Validators.required, Validators.minLength(7)]], + apiKey: ["", Validators.required], + service: ["", Validators.required], + }); + + constructor( + @Inject(DIALOG_DATA) protected connectInfo: DatadogConnectDialogParams, + protected formBuilder: FormBuilder, + private dialogRef: DialogRef, + private dialogService: DialogService, + ) {} + + ngOnInit(): void { + this.datadogConfig = + this.connectInfo.settings.organizationIntegration?.getConfiguration() ?? + null; + this.hecTemplate = + this.connectInfo.settings.organizationIntegration?.integrationConfiguration?.[0]?.getTemplate() ?? + null; + + this.formGroup.patchValue({ + url: this.datadogConfig?.uri || "", + apiKey: this.datadogConfig?.apiKey || "", + service: this.connectInfo.settings.name, + }); + } + + get isUpdateAvailable(): boolean { + return !!this.datadogConfig; + } + + get canDelete(): boolean { + return !!this.datadogConfig; + } + + submit = async (): Promise => { + if (this.formGroup.invalid) { + this.formGroup.markAllAsTouched(); + return; + } + const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Edited); + + this.dialogRef.close(result); + + return; + }; + + delete = async (): Promise => { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { + key: "deleteItemConfirmation", + }, + type: "warning", + }); + + if (confirmed) { + const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Delete); + this.dialogRef.close(result); + } + }; + + private getDatadogConnectDialogResult( + status: DatadogConnectDialogResultStatusType, + ): DatadogConnectDialogResult { + const formJson = this.formGroup.getRawValue(); + + return { + integrationSettings: this.connectInfo.settings, + url: formJson.url || "", + apiKey: formJson.apiKey || "", + service: formJson.service || "", + success: status, + }; + } +} + +export function openDatadogConnectDialog( + dialogService: DialogService, + config: DialogConfig>, +) { + return dialogService.open(ConnectDatadogDialogComponent, config); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts index 8c4891b9aa8..9852f3fe5c8 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts @@ -1 +1,2 @@ export * from "./connect-dialog/connect-dialog-hec.component"; +export * from "./connect-dialog/connect-dialog-datadog.component"; 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 f0b371703f6..2908fe0c089 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,6 +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 { IntegrationType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -24,6 +25,7 @@ describe("IntegrationGridComponent", () => { let fixture: ComponentFixture; const mockActivatedRoute = mock(); const mockIntegrationService = mock(); + const mockDatadogIntegrationService = mock(); const integrations: Integration[] = [ { name: "Integration 1", @@ -70,6 +72,7 @@ describe("IntegrationGridComponent", () => { useValue: mockActivatedRoute, }, { provide: HecOrganizationIntegrationService, useValue: mockIntegrationService }, + { provide: DatadogOrganizationIntegrationService, useValue: mockDatadogIntegrationService }, { provide: ToastService, useValue: mock(), 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 c249bf42282..539da9b31b1 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 @@ -4,6 +4,8 @@ import { firstValueFrom, Observable, Subject, switchMap, takeUntil, takeWhile } 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 { 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 { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -226,6 +228,7 @@ 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); }); // For all existing event based configurations loop through and assign the @@ -253,6 +256,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { private accountService: AccountService, private configService: ConfigService, private hecOrganizationIntegrationService: HecOrganizationIntegrationService, + private datadogOrganizationIntegrationService: DatadogOrganizationIntegrationService, ) { this.configService .getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations) @@ -270,10 +274,62 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { type: IntegrationType.EVENT, description: "crowdstrikeEventIntegrationDesc", canSetupConnection: true, + integrationType: 5, // Assuming 5 corresponds to CrowdStrike in OrganizationIntegrationType }; this.integrationsList.push(crowdstrikeIntegration); + + const datadogIntegration: Integration = { + name: OrganizationIntegrationServiceType.Datadog, + // TODO: Update link when help article is published + linkURL: "", + image: "../../../../../../../images/integrations/logo-datadog-color.svg", + type: IntegrationType.EVENT, + description: "datadogEventIntegrationDesc", + canSetupConnection: true, + integrationType: 6, // Assuming 6 corresponds to Datadog in OrganizationIntegrationType + }; + + this.integrationsList.push(datadogIntegration); } + + // For all existing event based configurations loop through and assign the + // organizationIntegration for the correct services. + this.hecOrganizationIntegrationService.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.Hec) { + 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); + if (item) { + item.organizationIntegration = integration; + } + }); + }); } ngOnDestroy(): void { this.destroy$.next(); 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 a8e0899f26d..e3c37b4a42b 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,5 +1,6 @@ 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"; @@ -12,6 +13,11 @@ import { OrganizationIntegrationsRoutingModule } from "./organization-integratio @NgModule({ imports: [AdminConsoleIntegrationsComponent, OrganizationIntegrationsRoutingModule], providers: [ + safeProvider({ + provide: DatadogOrganizationIntegrationService, + useClass: DatadogOrganizationIntegrationService, + deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService], + }), safeProvider({ provide: HecOrganizationIntegrationService, useClass: HecOrganizationIntegrationService, 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 bd105fc21e2..43d512439f0 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,6 +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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; @@ -37,6 +38,7 @@ class MockNewMenuComponent {} describe("IntegrationsComponent", () => { let fixture: ComponentFixture; const hecOrgIntegrationSvc = mock(); + const datadogOrgIntegrationSvc = mock(); const activatedRouteMock = { snapshot: { paramMap: { get: jest.fn() } }, @@ -55,6 +57,7 @@ describe("IntegrationsComponent", () => { { provide: I18nPipe, useValue: mock() }, { provide: I18nService, useValue: mockI18nService }, { provide: HecOrganizationIntegrationService, useValue: hecOrgIntegrationSvc }, + { provide: DatadogOrganizationIntegrationService, useValue: datadogOrgIntegrationSvc }, ], }).compileComponents(); fixture = TestBed.createComponent(IntegrationsComponent);